diff --git a/Cargo.toml b/Cargo.toml index f4a12ca6daf12..0909c32052989 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3873,6 +3873,17 @@ description = "An example to debug overflow and clipping behavior" category = "UI (User Interface)" wasm = true +[[example]] +name = "overflow_transform" +path = "examples/ui/scroll_and_overflow/overflow_transform.rs" +doc-scrape-examples = true + +[package.metadata.example.overflow_transform] +name = "Overflow Transform" +description = "Demonstrates nested transformed UI clipping" +category = "UI (User Interface)" +wasm = true + [[example]] name = "relative_cursor_position" path = "examples/ui/relative_cursor_position.rs" diff --git a/_release-content/migration-guides/calculated_clip_rects.md b/_release-content/migration-guides/calculated_clip_rects.md new file mode 100644 index 0000000000000..21efc61fa9987 --- /dev/null +++ b/_release-content/migration-guides/calculated_clip_rects.md @@ -0,0 +1,6 @@ +--- +title: "`CalculatedClip` now stores transformed clip rectangles" +pull_requests: [24148] +--- + +`CalculatedClip` is now an enum with `Rects` and `FullyClipped` variants. `Rects` holds a list of `Rect` in node local coords and `Affine2` world-to-local transform. diff --git a/crates/bevy_ui/src/focus.rs b/crates/bevy_ui/src/focus.rs index 101c5cf00a0bb..03f87c6216b49 100644 --- a/crates/bevy_ui/src/focus.rs +++ b/crates/bevy_ui/src/focus.rs @@ -1,14 +1,12 @@ use crate::{ - ui_transform::UiGlobalTransform, ComputedNode, ComputedUiTargetCamera, Node, OverrideClip, - UiStack, + ui_transform::UiGlobalTransform, CalculatedClip, ComputedNode, ComputedUiTargetCamera, UiStack, }; use bevy_camera::{visibility::InheritedVisibility, Camera, NormalizedRenderTarget, RenderTarget}; use bevy_ecs::{ change_detection::DetectChangesMut, entity::{ContainsEntity, Entity, EntityHashMap}, - hierarchy::ChildOf, prelude::{Component, With}, - query::{QueryData, Without}, + query::QueryData, reflect::ReflectComponent, system::{Local, Query, Res}, }; @@ -74,7 +72,7 @@ impl Default for Interaction { /// /// It can be used alongside [`Interaction`] to get the position of the press. /// -/// The component is updated when it is in the same entity with [`Node`]. +/// The component is updated when it is in the same entity with [`ComputedNode`]. #[derive(Component, Copy, Clone, Default, PartialEq, Debug, Reflect)] #[reflect(Component, Default, PartialEq, Debug, Clone)] #[cfg_attr( @@ -140,6 +138,7 @@ pub struct NodeQuery { focus_policy: Option<&'static FocusPolicy>, inherited_visibility: Option<&'static InheritedVisibility>, target_camera: &'static ComputedUiTargetCamera, + calculated_clip: Option<&'static CalculatedClip>, } /// The system that sets Interaction for all UI elements based on the mouse cursor activity @@ -155,8 +154,6 @@ pub fn ui_focus_system( touches_input: Res, ui_stack: Res, mut node_query: Query, - clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>, - child_of_query: Query<&ChildOf, Without>, ) { let primary_window = primary_window.iter().next(); @@ -257,7 +254,9 @@ pub fn ui_focus_system( let contains_cursor = cursor_position.is_some_and(|point| { node.node.contains_point(*node.transform, *point) - && clip_check_recursive(*point, entity, &clipping_query, &child_of_query) + && node + .calculated_clip + .is_none_or(|clip| clip.contains_point(*point)) }); // The mouse position relative to the node @@ -339,29 +338,3 @@ pub fn ui_focus_system( } } } - -/// Walk up the tree child-to-parent checking that `point` is not clipped by any ancestor node. -/// If `entity` has an [`OverrideClip`] component it ignores any inherited clipping and returns true. -pub fn clip_check_recursive( - point: Vec2, - entity: Entity, - clipping_query: &Query<'_, '_, (&ComputedNode, &UiGlobalTransform, &Node)>, - child_of_query: &Query<&ChildOf, Without>, -) -> bool { - if let Ok(child_of) = child_of_query.get(entity) - && let Ok((computed_node, transform, node)) = clipping_query.get(child_of.0) - && !node.overflow.is_visible() - { - if transform.try_inverse().is_none_or(|affine| { - !computed_node - .resolve_clip_rect(node.overflow, node.overflow_clip_margin) - .contains(affine.transform_point2(point)) - }) { - // The point is clipped (or transform not invertible) → ignore for picking - return false; - } - return clip_check_recursive(point, child_of.0, clipping_query, child_of_query); - } - // Reached root, point unclipped by all ancestors - true -} diff --git a/crates/bevy_ui/src/picking_backend.rs b/crates/bevy_ui/src/picking_backend.rs index a78776fd03376..b0fb9ab593153 100644 --- a/crates/bevy_ui/src/picking_backend.rs +++ b/crates/bevy_ui/src/picking_backend.rs @@ -21,7 +21,7 @@ //! `(-0.5, -0.5, 0.)` at the top left and `(0.5, 0.5, 0.)` in the bottom right. Coordinates are //! relative to the entire node, not just the visible region. This backend does not provide a `normal`. -use crate::{clip_check_recursive, prelude::*, ui_transform::UiGlobalTransform, UiStack}; +use crate::{prelude::*, ui_transform::UiGlobalTransform, UiStack}; use bevy_app::prelude::*; use bevy_camera::{visibility::InheritedVisibility, Camera, RenderTarget}; use bevy_ecs::{prelude::*, query::QueryData}; @@ -92,6 +92,7 @@ pub struct NodeQuery { inherited_visibility: Option<&'static InheritedVisibility>, target_camera: &'static ComputedUiTargetCamera, text_node: Option<(&'static TextLayoutInfo, &'static ComputedTextBlock)>, + calculated_clip: Option<&'static CalculatedClip>, } /// Computes the UI node entities under each pointer. @@ -106,8 +107,6 @@ pub fn ui_picking( ui_stack: Res, node_query: Query, mut output: MessageWriter, - clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>, - child_of_query: Query<&ChildOf, Without>, pickable_query: Query<&Pickable>, ) { // Map from each camera to its active pointers and their positions in viewport space @@ -204,12 +203,9 @@ pub fn ui_picking( // Coordinates are relative to the entire node, not just the visible region. for (pointer_id, cursor_position) in pointers_on_this_cam.iter() { if node.node.contains_point(*node.transform, *cursor_position) - && clip_check_recursive( - *cursor_position, - node_entity, - &clipping_query, - &child_of_query, - ) + && node + .calculated_clip + .is_none_or(|clip| clip.contains_point(*cursor_position)) && let Some(target) = node .text_node .and_then(|(text_layout_info, text_block)| { diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 195d780f347cf..eee8bfdda968c 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -6,7 +6,7 @@ use bevy_camera::{visibility::Visibility, Camera, RenderTarget}; use bevy_color::{Alpha, Color}; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{prelude::*, system::SystemParam}; -use bevy_math::{BVec2, Rect, UVec2, Vec2, Vec4, Vec4Swizzles}; +use bevy_math::{Affine2, BVec2, Rect, UVec2, Vec2, Vec4, Vec4Swizzles}; use bevy_reflect::prelude::*; use bevy_sprite::BorderRect; use bevy_utils::once; @@ -2393,12 +2393,92 @@ impl Outline { } } -/// The calculated clip of the node -#[derive(Component, Default, Copy, Clone, Debug, Reflect)] -#[reflect(Component, Default, Debug, Clone)] -pub struct CalculatedClip { - /// The rect of the clip - pub clip: Rect, +/// A single local-space clipping rect. +#[derive(Copy, Clone, Debug, PartialEq, Reflect)] +#[reflect(Default, Debug, PartialEq, Clone)] +pub struct CalculatedClipRect { + /// The clip rect in the clipping node's local space. + pub rect: Rect, + /// Transform from world space into the clipping node's local space. + pub world_to_clip_local: Affine2, +} + +impl Default for CalculatedClipRect { + fn default() -> Self { + Self { + rect: Rect::default(), + world_to_clip_local: Affine2::IDENTITY, + } + } +} + +/// The calculated clipping inherited by the node. +#[derive(Component, Clone, Debug, PartialEq, Reflect)] +#[reflect(Component, Default, Debug, PartialEq, Clone)] +pub enum CalculatedClip { + /// Clip rects inherited from ancestors. + Rects(SmallVec<[CalculatedClipRect; 2]>), + /// The node and descendants are fully clipped. + FullyClipped, +} + +impl Default for CalculatedClip { + fn default() -> Self { + Self::Rects(SmallVec::new()) + } +} + +impl CalculatedClip { + pub fn with_rect(&self, rect: Rect, transform: &UiGlobalTransform) -> Self { + if !self.is_fully_clipped() + && let Some(world_to_local_clip) = transform.try_inverse() + { + let mut clip = self.clone(); + clip.push_rect(rect, world_to_local_clip); + clip + } else { + CalculatedClip::FullyClipped + } + } + + /// Returns true if the node and descendants are fully clipped. + #[inline] + pub const fn is_fully_clipped(&self) -> bool { + matches!(self, Self::FullyClipped) + } + + /// Returns the inherited clipping rects, if this node is not fully clipped. + #[inline] + pub fn rects(&self) -> Option<&[CalculatedClipRect]> { + match self { + Self::Rects(rects) => Some(rects), + Self::FullyClipped => None, + } + } + + /// Returns true if the point is contained by all inherited clipping rects. + #[inline] + pub fn contains_point(&self, point: Vec2) -> bool { + match self { + Self::Rects(rects) => rects.iter().all(|clip_rect| { + clip_rect + .rect + .contains(clip_rect.world_to_clip_local.transform_point2(point)) + }), + Self::FullyClipped => false, + } + } + + /// Adds a clipping rect if this node is not fully clipped. + #[inline] + pub fn push_rect(&mut self, rect: Rect, world_to_clip_local: Affine2) { + if let Self::Rects(rects) = self { + rects.push(CalculatedClipRect { + rect, + world_to_clip_local, + }); + } + } } /// UI node entities with this component will ignore any clipping rect they inherit, diff --git a/crates/bevy_ui/src/update.rs b/crates/bevy_ui/src/update.rs index fc8347dd68e11..ec8a4e429951d 100644 --- a/crates/bevy_ui/src/update.rs +++ b/crates/bevy_ui/src/update.rs @@ -15,7 +15,7 @@ use bevy_ecs::{ query::Has, system::{Commands, Query, Res}, }; -use bevy_math::{Rect, UVec2}; +use bevy_math::UVec2; /// Updates clipping for all nodes pub fn update_clipping_system( @@ -52,7 +52,7 @@ fn update_clipping( Has, )>, entity: Entity, - mut maybe_inherited_clip: Option, + mut maybe_inherited_clip: Option, ) { let Ok((node, computed_node, transform, maybe_calculated_clip, has_override_clip)) = node_query.get_mut(entity) @@ -65,50 +65,54 @@ fn update_clipping( maybe_inherited_clip = None; } - // If `display` is None, clip the entire node and all its descendants by replacing the inherited clip with a default rect (which is empty) + // If `display` is None, clip the entire node and all its descendants. if node.display == Display::None { - maybe_inherited_clip = Some(Rect::default()); + maybe_inherited_clip = Some(CalculatedClip::FullyClipped); } // Update this node's CalculatedClip component if let Some(mut calculated_clip) = maybe_calculated_clip { - if let Some(inherited_clip) = maybe_inherited_clip { + if let Some(inherited_clip) = maybe_inherited_clip.as_ref() { // Replace the previous calculated clip with the inherited clipping rect - if calculated_clip.clip != inherited_clip { - *calculated_clip = CalculatedClip { - clip: inherited_clip, - }; + if *calculated_clip != *inherited_clip { + *calculated_clip = inherited_clip.clone(); } } else { // No inherited clipping rect, remove the component commands.entity(entity).remove::(); } - } else if let Some(inherited_clip) = maybe_inherited_clip { + } else if let Some(inherited_clip) = maybe_inherited_clip.as_ref() { // No previous calculated clip, add a new CalculatedClip component with the inherited clipping rect - commands.entity(entity).try_insert(CalculatedClip { - clip: inherited_clip, - }); + commands.entity(entity).try_insert(inherited_clip.clone()); } // Calculate new clip rectangle for children nodes - let children_clip = if node.overflow.is_visible() { + let children_clip = if maybe_inherited_clip + .as_ref() + .is_some_and(CalculatedClip::is_fully_clipped) + || node.overflow.is_visible() + { // The current node doesn't clip, propagate the optional inherited clipping rect to any children maybe_inherited_clip + } else if let Some(clip_from_world) = transform.try_inverse() { + let mut clip = maybe_inherited_clip.unwrap_or_default(); + clip.push_rect( + computed_node.resolve_clip_rect(node.overflow, node.overflow_clip_margin), + clip_from_world, + ); + Some(clip) } else { - // Find the current node's clipping rect and intersect it with the inherited clipping rect, if one exists - // Content isn't clipped at the edges of the node but at the edges of the region specified by [`Node::overflow_clip_margin`]. - // - // `clip_inset` should always fit inside `node_rect`. - // Even if `clip_inset` were to overflow, we won't return a degenerate result as `Rect::intersect` will clamp the intersection, leaving it empty. - let mut clip_rect = - computed_node.resolve_clip_rect(node.overflow, node.overflow_clip_margin); - clip_rect.min += transform.translation; - clip_rect.max += transform.translation; - Some(maybe_inherited_clip.map_or(clip_rect, |c| c.intersect(clip_rect))) + Some(CalculatedClip::FullyClipped) }; for child in ui_children.iter_ui_children(entity) { - update_clipping(commands, ui_children, node_query, child, children_clip); + update_clipping( + commands, + ui_children, + node_query, + child, + children_clip.clone(), + ); } } diff --git a/crates/bevy_ui_render/Cargo.toml b/crates/bevy_ui_render/Cargo.toml index 88ec27815fb98..9d698ae6d77f1 100644 --- a/crates/bevy_ui_render/Cargo.toml +++ b/crates/bevy_ui_render/Cargo.toml @@ -41,6 +41,7 @@ bytemuck = { version = "1.5", features = ["derive"] } derive_more = { version = "2", default-features = false, features = ["from"] } tracing = { version = "0.1", default-features = false, features = ["std"] } indexmap = { version = "2" } +smallvec = { version = "1", default-features = false } [features] default = ["bevy_ui_debug"] diff --git a/crates/bevy_ui_render/src/box_shadow.rs b/crates/bevy_ui_render/src/box_shadow.rs index cfcddc00fdade..8c0edaa41b5a8 100644 --- a/crates/bevy_ui_render/src/box_shadow.rs +++ b/crates/bevy_ui_render/src/box_shadow.rs @@ -14,7 +14,7 @@ use bevy_ecs::{ *, }, }; -use bevy_math::{vec2, Affine2, FloatOrd, Rect, Vec2}; +use bevy_math::{vec2, Affine2, FloatOrd, Vec2}; use bevy_mesh::VertexBufferLayout; use bevy_render::sync_world::{MainEntity, TemporaryRenderEntity}; use bevy_render::{ @@ -33,9 +33,11 @@ use bevy_ui::{ use bevy_utils::default; use bytemuck::{Pod, Zeroable}; -use crate::{BoxShadowSamples, RenderUiSystems, TransparentUi, UiCameraMap}; +use crate::{ + clipping::clip_polygon, BoxShadowSamples, RenderUiSystems, TransparentUi, UiCameraMap, +}; -use super::{stack_z_offsets, UiCameraView, QUAD_INDICES, QUAD_VERTEX_POSITIONS}; +use super::{stack_z_offsets, UiCameraView, QUAD_VERTEX_POSITIONS}; /// A plugin that enables the rendering of box shadows. pub struct BoxShadowPlugin; @@ -187,7 +189,7 @@ pub struct ExtractedBoxShadow { pub stack_index: u32, pub transform: Affine2, pub bounds: Vec2, - pub clip: Option, + pub clip: Option, pub extracted_camera_entity: Entity, pub color: LinearRgba, pub radius: ResolvedBorderRadius, @@ -282,7 +284,7 @@ pub fn extract_shadows( transform: Affine2::from(transform) * Affine2::from_translation(offset), color: drop_shadow.color.into(), bounds: shadow_size + 6. * blur_radius, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), extracted_camera_entity, radius, blur_radius, @@ -388,83 +390,35 @@ pub fn prepare_shadows( let rect_size = box_shadow.bounds; // Specify the corners of the node - let positions = QUAD_VERTEX_POSITIONS.map(|pos| { - box_shadow - .transform - .transform_point2(pos * rect_size) - .extend(0.) - }); - - // 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_vector2(rect_size).abs(); - - // 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 positions = QUAD_VERTEX_POSITIONS + .map(|pos| box_shadow.transform.transform_point2(pos * rect_size)); let uvs = [ - Vec2::new(positions_diff[0].x, positions_diff[0].y), - Vec2::new( - box_shadow.bounds.x + positions_diff[1].x, - positions_diff[1].y, - ), - Vec2::new( - box_shadow.bounds.x + positions_diff[2].x, - box_shadow.bounds.y + positions_diff[2].y, - ), - Vec2::new( - positions_diff[3].x, - box_shadow.bounds.y + positions_diff[3].y, - ), + Vec2::ZERO, + Vec2::new(box_shadow.bounds.x, 0.), + box_shadow.bounds, + Vec2::new(0., box_shadow.bounds.y), ] .map(|pos| pos / box_shadow.bounds); - for i in 0..4 { + let vertices = clip_polygon( + box_shadow.clip.as_ref(), + &[ + (positions[0], uvs[0]), + (positions[1], uvs[1]), + (positions[2], uvs[2]), + (positions[3], uvs[3]), + ], + Vec2::lerp, + ); + if vertices.is_empty() { + continue; + } + + for vertex in &vertices { ui_meta.vertices.push(BoxShadowVertex { - position: positions_clipped[i].into(), - uvs: uvs[i].into(), + position: vertex.0.extend(0.).into(), + uvs: vertex.1.into(), vertex_color: box_shadow.color.to_f32_array(), size: box_shadow.size.into(), radius: box_shadow.radius.into(), @@ -473,20 +427,24 @@ pub fn prepare_shadows( }); } - for &i in &QUAD_INDICES { - ui_meta.indices.push(indices_index + i as u32); + for i in 1..vertices.len() as u32 - 1 { + ui_meta.indices.push(indices_index); + ui_meta.indices.push(indices_index + i); + ui_meta.indices.push(indices_index + i + 1); } + let index_count = 3 * (vertices.len() as u32 - 2); + batches.push(( item.entity(), UiShadowsBatch { - range: vertices_index..vertices_index + 6, + range: vertices_index..vertices_index + index_count, camera: box_shadow.extracted_camera_entity, }, )); - vertices_index += 6; - indices_index += 4; + vertices_index += index_count; + indices_index += vertices.len() as u32; // shadows are sent to the gpu non-batched *ui_phase.items[item_index].batch_range_mut() = diff --git a/crates/bevy_ui_render/src/clipping.rs b/crates/bevy_ui_render/src/clipping.rs new file mode 100644 index 0000000000000..29768c78c54c6 --- /dev/null +++ b/crates/bevy_ui_render/src/clipping.rs @@ -0,0 +1,199 @@ +use bevy_math::{Affine2, Vec2}; +use bevy_ui::CalculatedClip; +use smallvec::SmallVec; + +const INLINE_CAPACITY: usize = 16; + +/// Clips a polygon using the [Sutherland-Hodgman](https://en.wikipedia.org/wiki/Sutherland-Hodgman_algorithm) +/// algorithm and interpolates the attribute values. +/// +/// # Arguments +/// * `clip` - The clipping regions to apply. If `None`, the input polygon is returned unchanged. +/// * `vertices` - The polygon vertices and associated attribute values. The vertices should be in boundary order (either direction), forming a convex polygon. +/// * `interpolate` - Interpolates attribute values for new vertices at clip intersections. +/// +/// Returns the resulting clipped polygon as a list of vertices forming a triangle fan. +pub fn clip_polygon( + clip: Option<&CalculatedClip>, + vertices: &[(Vec2, T)], + interpolate: impl Fn(T, T, f32) -> T + Copy, +) -> SmallVec<[(Vec2, T); INLINE_CAPACITY]> { + // If less than 3 vertices, there's no visible region to clip. + if vertices.len() < 3 { + return SmallVec::new(); + } + + let Some(clip) = clip else { + return SmallVec::from_slice(vertices); + }; + let Some(rects) = clip.rects() else { + return SmallVec::new(); + }; + + let mut visible_region = SmallVec::from_slice(vertices); + let mut scratch = SmallVec::new(); + + for region in rects { + if visible_region.len() < 3 { + break; + } + + for (edge, distance_normal) in [ + (-region.rect.min.x, Vec2::X), + (region.rect.max.x, Vec2::NEG_X), + (region.rect.max.y, Vec2::NEG_Y), + (-region.rect.min.y, Vec2::Y), + ] { + if edge.is_finite() { + edge_clip( + &visible_region, + &mut scratch, + region.world_to_clip_local, + edge, + distance_normal, + interpolate, + ); + core::mem::swap(&mut visible_region, &mut scratch); + } + } + } + + if visible_region.len() < 3 { + visible_region.clear(); + } + + visible_region +} + +fn edge_clip( + input: &[(Vec2, T)], + output: &mut SmallVec<[(Vec2, T); INLINE_CAPACITY]>, + world_to_clip: Affine2, + edge: f32, + distance_normal: Vec2, + interpolate: impl Fn(T, T, f32) -> T + Copy, +) { + output.clear(); + + let Some(mut previous) = input.last().copied() else { + return; + }; + let mut previous_distance = world_to_clip + .transform_point2(previous.0) + .dot(distance_normal) + + edge; + let mut is_previous_visible = 0. <= previous_distance; + + for &vertex in input { + let distance = world_to_clip + .transform_point2(vertex.0) + .dot(distance_normal) + + edge; + let is_visible = 0. <= distance; + // If inside != previous_inside, the previous -> vertex edge crossed the clip rect edge and we + // add a new vertex at the intersection. + if is_visible != is_previous_visible { + let t = previous_distance / (previous_distance - distance); + output.push(( + previous.0.lerp(vertex.0, t), + interpolate(previous.1, vertex.1, t), + )); + } + if is_visible { + output.push(vertex); + } + previous = vertex; + previous_distance = distance; + is_previous_visible = is_visible; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_math::{vec2, Affine2, Mat2, Rect, Rot2}; + use bevy_ui::{CalculatedClip, CalculatedClipRect}; + + fn calculated_clip(regions: impl IntoIterator) -> CalculatedClip { + CalculatedClip::Rects(regions.into_iter().collect()) + } + + fn quad() -> [(Vec2, Vec2); 4] { + [ + (vec2(-1., -1.), vec2(-1., -1.)), + (vec2(1., -1.), vec2(1., -1.)), + (vec2(1., 1.), vec2(1., 1.)), + (vec2(-1., 1.), vec2(-1., 1.)), + ] + } + + #[test] + fn unclipped_quad_returns_all_vertices() { + assert_eq!(clip_polygon(None, &quad(), Vec2::lerp).len(), 4); + } + + #[test] + fn fully_clipped_returns_empty_vertices_list() { + assert!(clip_polygon(Some(&CalculatedClip::FullyClipped), &quad(), Vec2::lerp).is_empty()); + } + + #[test] + fn trim_quad_with_axis_aligned_clip() { + let clip = calculated_clip([CalculatedClipRect { + rect: Rect { + min: vec2(0., -0.5), + max: vec2(0.5, 0.5), + }, + world_to_clip_local: Affine2::IDENTITY, + }]); + let clipped = clip_polygon(Some(&clip), &quad(), Vec2::lerp); + + assert_eq!(clipped.len(), 4); + assert!(clipped.iter().all(|(v, _)| 0. <= v.x && v.x <= 0.5)); + assert!(clipped.iter().all(|(v, _)| -0.5 <= v.y && v.y <= 0.5)); + } + + #[test] + fn nested_clip_rects_compose() { + let vertices = clip_polygon( + Some(&calculated_clip([ + CalculatedClipRect { + rect: Rect { + min: vec2(-0.75, -0.75), + max: vec2(0.75, 0.75), + }, + world_to_clip_local: Affine2::IDENTITY, + }, + CalculatedClipRect { + rect: Rect { + min: vec2(-0.25, -1.), + max: vec2(0.25, 1.), + }, + world_to_clip_local: Affine2::from_mat2(Mat2::from(Rot2::radians(0.3))) + .inverse(), + }, + ])), + &quad(), + Vec2::lerp, + ); + + assert!(!vertices.is_empty()); + assert!(vertices.iter().all(|(v, _)| -0.75 <= v.x && v.x <= 0.75)); + } + + #[test] + fn quad_outside_clip_rect_returns_empty_vertices_list() { + assert!(clip_polygon( + Some(&calculated_clip([CalculatedClipRect { + rect: Rect { + min: vec2(2., 2.), + max: vec2(3., 3.), + }, + world_to_clip_local: Affine2::IDENTITY, + }])), + &quad(), + Vec2::lerp + ) + .is_empty()); + } +} diff --git a/crates/bevy_ui_render/src/debug_overlay.rs b/crates/bevy_ui_render/src/debug_overlay.rs index 304b36e496571..932f1da0de30b 100644 --- a/crates/bevy_ui_render/src/debug_overlay.rs +++ b/crates/bevy_ui_render/src/debug_overlay.rs @@ -221,9 +221,9 @@ pub fn extract_debug_overlay( render_entity: commands.spawn(TemporaryRenderEntity).id(), // Keep all overlays above UI, and nudge each type slightly in Z so ordering is stable. z_order, - clip: maybe_clip - .filter(|_| !debug_options.show_clipped) - .map(|clip| clip.clip), + clip: (!debug_options.show_clipped) + .then(|| maybe_clip.cloned()) + .flatten(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(rect.center()), diff --git a/crates/bevy_ui_render/src/gradient.rs b/crates/bevy_ui_render/src/gradient.rs index 0642e99769bdf..5d4bcd348152f 100644 --- a/crates/bevy_ui_render/src/gradient.rs +++ b/crates/bevy_ui_render/src/gradient.rs @@ -5,6 +5,7 @@ use core::{ }; use super::shader_flags::BORDER_ALL; +use crate::clipping::clip_polygon; use crate::*; use bevy_asset::*; use bevy_color::{ColorToComponents, Hsla, Hsva, LinearRgba, Oklaba, Oklcha, Srgba}; @@ -230,7 +231,7 @@ pub struct ExtractedGradient { pub stack_index: u32, pub transform: Affine2, pub rect: Rect, - pub clip: Option, + pub clip: Option, pub extracted_camera_entity: Entity, /// range into `ExtractedColorStops` pub stops_range: Range, @@ -399,7 +400,7 @@ pub fn extract_gradients( NodeType::Border(_) => stack_z_offsets::BORDER_GRADIENT, }, image: AssetId::default(), - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), extracted_camera_entity, transform: transform.into(), item: ExtractedUiItem::Node { @@ -448,7 +449,7 @@ pub fn extract_gradients( min: Vec2::ZERO, max: uinode.size, }, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), extracted_camera_entity, main_entity: entity.into(), node_type, @@ -498,7 +499,7 @@ pub fn extract_gradients( min: Vec2::ZERO, max: uinode.size, }, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), extracted_camera_entity, main_entity: entity.into(), node_type, @@ -554,7 +555,7 @@ pub fn extract_gradients( min: Vec2::ZERO, max: uinode.size, }, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), extracted_camera_entity, main_entity: entity.into(), node_type, @@ -728,71 +729,10 @@ pub fn prepare_gradient( let rect_size = uinode_rect.size(); // Specify the corners of the node - let positions = QUAD_VERTEX_POSITIONS.map(|pos| { - gradient - .transform - .transform_point2(pos * rect_size) - .extend(0.) - }); + let positions = QUAD_VERTEX_POSITIONS + .map(|pos| gradient.transform.transform_point2(pos * rect_size)); let corner_points = QUAD_VERTEX_POSITIONS.map(|pos| pos * rect_size); - // 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) = gradient.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 points = [ - corner_points[0] + positions_diff[0], - corner_points[1] + positions_diff[1], - corner_points[2] + positions_diff[2], - corner_points[3] + positions_diff[3], - ]; - - let transformed_rect_size = - gradient.transform.transform_vector2(rect_size).abs(); - - // 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 gradient.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 uvs = { [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y] }; let mut flags = if let NodeType::Border(borders) = gradient.node_type { @@ -823,6 +763,21 @@ pub fn prepare_gradient( flags |= g_flags; + let vertices = clip_polygon( + gradient.clip.as_ref(), + &[ + (positions[0], (uvs[0], corner_points[0])), + (positions[1], (uvs[1], corner_points[1])), + (positions[2], (uvs[2], corner_points[2])), + (positions[3], (uvs[3], corner_points[3])), + ], + |a, b, t| (a.0.lerp(b.0, t), a.1.lerp(b.1, t)), + ); + if vertices.is_empty() { + continue; + } + let segment_index_count = 3 * (vertices.len() as u32 - 2); + let range = gradient.stops_range.start..gradient.stops_range.end - 1; let mut segment_count = 0; @@ -851,11 +806,11 @@ pub fn prepare_gradient( stop_flags |= shader_flags::FILL_END; } - for i in 0..4 { + for &(position, (uv, point)) in &vertices { ui_meta.vertices.push(UiGradientVertex { - position: positions_clipped[i].into(), - uv: uvs[i].into(), - flags: stop_flags | shader_flags::CORNERS[i], + position: position.extend(0.).into(), + uv: uv.into(), + flags: stop_flags, radius: [ gradient.border_radius.top_left, gradient.border_radius.top_right, @@ -871,7 +826,7 @@ pub fn prepare_gradient( size: rect_size.xy().into(), g_start, g_dir, - point: points[i].into(), + point: point.into(), start_color, start_len: start_stop.1, end_len: end_stop.1, @@ -880,15 +835,17 @@ pub fn prepare_gradient( }); } - for &i in &QUAD_INDICES { - ui_meta.indices.push(indices_index + i as u32); + for i in 1..vertices.len() as u32 - 1 { + ui_meta.indices.push(indices_index); + ui_meta.indices.push(indices_index + i); + ui_meta.indices.push(indices_index + i + 1); } - indices_index += 4; + indices_index += vertices.len() as u32; segment_count += 1; } if 0 < segment_count { - let vertices_count = 6 * segment_count; + let vertices_count = segment_index_count * segment_count; batches.push(( item.entity(), diff --git a/crates/bevy_ui_render/src/lib.rs b/crates/bevy_ui_render/src/lib.rs index 48944a85dabbf..1eeb02ca9b38a 100644 --- a/crates/bevy_ui_render/src/lib.rs +++ b/crates/bevy_ui_render/src/lib.rs @@ -8,6 +8,7 @@ //! Provides rendering functionality for `bevy_ui`. pub mod box_shadow; +pub mod clipping; mod gradient; mod pipeline; pub mod render_pass; @@ -80,7 +81,8 @@ pub use ui_material_pipeline::*; use ui_texture_slice_pipeline::UiTextureSlicerPlugin; use crate::shader_flags::INVERT; -use crate::text::{extract_preedit_underlines, extract_text_cursor}; +use crate::text::{calculate_text_scroll_clip, extract_preedit_underlines, extract_text_cursor}; +use clipping::clip_polygon; pub mod prelude { #[cfg(feature = "bevy_ui_debug")] @@ -327,7 +329,7 @@ impl<'w, 's> UiCameraMapper<'w, 's> { pub struct ExtractedUiNode { pub z_order: f32, pub image: AssetId, - pub clip: Option, + pub clip: Option, /// Render world entity of the extracted camera corresponding to this node's target camera. pub extracted_camera_entity: Entity, pub item: ExtractedUiItem, @@ -435,7 +437,7 @@ pub fn extract_uinode_background_colors( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: stack_index.0 as f32 + stack_z_offsets::BACKGROUND_COLOR, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), image: AssetId::default(), extracted_camera_entity, transform: transform.into(), @@ -462,7 +464,7 @@ pub fn extract_uinode_background_colors( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: stack_index.0 as f32 + stack_z_offsets::BACKGROUND_COLOR, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), image: AssetId::default(), extracted_camera_entity, transform: transform.into(), @@ -579,7 +581,7 @@ pub fn extract_uinode_images( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::IMAGE, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), image: image.image.id(), extracted_camera_entity, transform: Affine2::from(*transform) * Affine2::from_translation(visual_box.center()), @@ -680,7 +682,7 @@ pub fn extract_uinode_borders( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::BORDER, image, - clip: maybe_clip.map(|clip| clip.clip), + clip: maybe_clip.cloned(), extracted_camera_entity, transform: transform.into(), item: ExtractedUiItem::Node { @@ -713,7 +715,7 @@ pub fn extract_uinode_borders( z_order: stack_index.0 as f32 + stack_z_offsets::BORDER, render_entity: commands.spawn(TemporaryRenderEntity).id(), image, - clip: maybe_clip.map(|clip| clip.clip), + clip: maybe_clip.cloned(), extracted_camera_entity, transform: transform.into(), item: ExtractedUiItem::Node { @@ -930,7 +932,7 @@ pub fn extract_viewport_nodes( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::IMAGE, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), image: image.id(), extracted_camera_entity, transform: transform.into(), @@ -1007,16 +1009,7 @@ pub fn extract_text_sections( uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), ); - let clip = if text_scroll.is_some() { - let content_box = uinode.content_box(); - let text_clip = Rect::from_center_size( - global_transform.affine().translation + content_box.center(), - content_box.size(), - ); - Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) - } else { - maybe_clip.map(|clip| clip.clip) - }; + let clip = calculate_text_scroll_clip(text_scroll, maybe_clip, uinode, global_transform); let mut color = text_color.0.to_linear(); @@ -1081,7 +1074,7 @@ pub fn extract_text_sections( z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), image: atlas_info.texture, - clip, + clip: clip.clone(), extracted_camera_entity, item: ExtractedUiItem::Glyphs { range: start..end }, main_entity: entity.into(), @@ -1149,16 +1142,7 @@ pub fn extract_text_shadows( - text_scroll.map_or(Vec2::ZERO, |s| s.0), ); - let clip = if text_scroll.is_some() { - let content_box = uinode.content_box(); - let text_clip = Rect::from_center_size( - global_transform.affine().translation + content_box.center(), - content_box.size(), - ); - Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) - } else { - maybe_clip.map(|clip| clip.clip) - }; + let clip = calculate_text_scroll_clip(text_scroll, maybe_clip, uinode, global_transform); for ( i, @@ -1185,7 +1169,7 @@ pub fn extract_text_shadows( z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), image: atlas_info.texture, - clip, + clip: clip.clone(), extracted_camera_entity, item: ExtractedUiItem::Glyphs { range: start..end }, main_entity: entity.into(), @@ -1213,7 +1197,7 @@ pub fn extract_text_shadows( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: node_transform @@ -1239,7 +1223,7 @@ pub fn extract_text_shadows( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: node_transform * Affine2::from_translation(run.underline_position()), @@ -1318,16 +1302,7 @@ pub fn extract_text_decorations( uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), ); - let clip = if text_scroll.is_some() { - let content_box = uinode.content_box(); - let text_clip = Rect::from_center_size( - global_transform.affine().translation + content_box.center(), - content_box.size(), - ); - Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) - } else { - maybe_clip.map(|clip| clip.clip) - }; + let clip = calculate_text_scroll_clip(text_scroll, maybe_clip, uinode, global_transform); for run in text_layout_info.run_geometry.iter() { let Some(section_entity) = computed_block @@ -1351,7 +1326,7 @@ pub fn extract_text_decorations( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::TEXT, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(run.bounds.center()), @@ -1381,7 +1356,7 @@ pub fn extract_text_decorations( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(run.strikethrough_position()), @@ -1411,7 +1386,7 @@ pub fn extract_text_decorations( extracted_uinodes.uinodes.push(ExtractedUiNode { z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, render_entity: commands.spawn(TemporaryRenderEntity).id(), - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(run.underline_position()), @@ -1480,8 +1455,6 @@ pub(crate) const QUAD_VERTEX_POSITIONS: [Vec2; 4] = [ Vec2::new(-0.5, 0.5), ]; -pub(crate) const QUAD_INDICES: [usize; 6] = [0, 2, 3, 0, 1, 2]; - #[derive(Component)] pub struct UiBatch { pub range: Range, @@ -1712,76 +1685,19 @@ pub fn prepare_uinodes( shader_flags::UNTEXTURED }; - let mut uinode_rect = *rect; - - let rect_size = uinode_rect.size(); + let rect_size = rect.size(); let transform = extracted_uinode.transform; // Specify the corners of the node let positions = QUAD_VERTEX_POSITIONS - .map(|pos| transform.transform_point2(pos * rect_size).extend(0.)); + .map(|pos| transform.transform_point2(pos * rect_size)); let points = QUAD_VERTEX_POSITIONS.map(|pos| pos * rect_size); - // 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 mut positions_diff = if let Some(clip) = extracted_uinode.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 points = [ - points[0] + positions_diff[0], - points[1] + positions_diff[1], - points[2] + positions_diff[2], - points[3] + positions_diff[3], - ]; - - let transformed_rect_size = transform.transform_vector2(rect_size).abs(); - - // 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 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 uvs = if flags == shader_flags::UNTEXTURED { [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y] } else { + let mut uinode_rect = *rect; let image = gpu_images .get(extracted_uinode.image) .expect("Image was checked during batching and should still exist"); @@ -1791,35 +1707,15 @@ pub fn prepare_uinodes( .unwrap_or(uinode_rect.max); if *flip_x { core::mem::swap(&mut uinode_rect.max.x, &mut uinode_rect.min.x); - positions_diff[0].x *= -1.; - positions_diff[1].x *= -1.; - positions_diff[2].x *= -1.; - positions_diff[3].x *= -1.; } if *flip_y { core::mem::swap(&mut uinode_rect.max.y, &mut uinode_rect.min.y); - positions_diff[0].y *= -1.; - positions_diff[1].y *= -1.; - positions_diff[2].y *= -1.; - positions_diff[3].y *= -1.; } [ - Vec2::new( - uinode_rect.min.x + positions_diff[0].x, - uinode_rect.min.y + positions_diff[0].y, - ), - Vec2::new( - uinode_rect.max.x + positions_diff[1].x, - uinode_rect.min.y + positions_diff[1].y, - ), - Vec2::new( - uinode_rect.max.x + positions_diff[2].x, - uinode_rect.max.y + positions_diff[2].y, - ), - Vec2::new( - uinode_rect.min.x + positions_diff[3].x, - uinode_rect.max.y + positions_diff[3].y, - ), + Vec2::new(uinode_rect.min.x, uinode_rect.min.y), + Vec2::new(uinode_rect.max.x, uinode_rect.min.y), + Vec2::new(uinode_rect.max.x, uinode_rect.max.y), + Vec2::new(uinode_rect.min.x, uinode_rect.max.y), ] .map(|pos| pos / atlas_extent) }; @@ -1835,12 +1731,26 @@ pub fn prepare_uinodes( _ => {} } - for i in 0..4 { + let vertices = clip_polygon( + extracted_uinode.clip.as_ref(), + &[ + (positions[0], (uvs[0], points[0])), + (positions[1], (uvs[1], points[1])), + (positions[2], (uvs[2], points[2])), + (positions[3], (uvs[3], points[3])), + ], + |a, b, t| (a.0.lerp(b.0, t), a.1.lerp(b.1, t)), + ); + if vertices.is_empty() { + continue; + } + + for &(position, (uv, point)) in &vertices { ui_meta.vertices.push(UiVertex { - position: positions_clipped[i].into(), - uv: uvs[i].into(), + position: position.extend(0.).into(), + uv: uv.into(), color, - flags: flags | shader_flags::CORNERS[i], + flags, radius: (*border_radius).into(), border: [ border.min_inset.x, @@ -1849,16 +1759,18 @@ pub fn prepare_uinodes( border.max_inset.y, ], size: rect_size.into(), - point: points[i].into(), + point: point.into(), }); } - for &i in &QUAD_INDICES { - ui_meta.indices.push(indices_index + i as u32); + for i in 1..vertices.len() as u32 - 1 { + ui_meta.indices.push(indices_index); + ui_meta.indices.push(indices_index + i); + ui_meta.indices.push(indices_index + i + 1); } - vertices_index += 6; - indices_index += 4; + vertices_index += 3 * (vertices.len() as u32 - 2); + indices_index += vertices.len() as u32; } ExtractedUiItem::Glyphs { range } => { let image = gpu_images @@ -1877,77 +1789,44 @@ pub fn prepare_uinodes( extracted_uinode .transform .transform_point2(glyph.translation + pos * glyph_rect.size()) - .extend(0.) }); - let positions_diff = if let Some(clip) = extracted_uinode.clip { - [ - Vec2::new( - f32::max(clip.min.x - positions[0].x, 0.), - f32::max(clip.min.y - positions[0].y, 0.), + let vertices = clip_polygon( + extracted_uinode.clip.as_ref(), + &[ + ( + positions[0], + Vec2::new(glyph.rect.min.x, glyph.rect.min.y) + / atlas_extent, ), - Vec2::new( - f32::min(clip.max.x - positions[1].x, 0.), - f32::max(clip.min.y - positions[1].y, 0.), + ( + positions[1], + Vec2::new(glyph.rect.max.x, glyph.rect.min.y) + / atlas_extent, ), - Vec2::new( - f32::min(clip.max.x - positions[2].x, 0.), - f32::min(clip.max.y - positions[2].y, 0.), + ( + positions[2], + Vec2::new(glyph.rect.max.x, glyph.rect.max.y) + / atlas_extent, ), - Vec2::new( - f32::max(clip.min.x - positions[3].x, 0.), - f32::min(clip.max.y - positions[3].y, 0.), + ( + positions[3], + Vec2::new(glyph.rect.min.x, glyph.rect.max.y) + / atlas_extent, ), - ] - } 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.), - ]; - - // cull nodes that are completely clipped - let transformed_rect_size = extracted_uinode - .transform - .transform_vector2(rect_size) - .abs(); - 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 - { + ], + Vec2::lerp, + ); + if vertices.is_empty() { continue; } - let uvs = [ - Vec2::new( - glyph.rect.min.x + positions_diff[0].x, - glyph.rect.min.y + positions_diff[0].y, - ), - Vec2::new( - glyph.rect.max.x + positions_diff[1].x, - glyph.rect.min.y + positions_diff[1].y, - ), - Vec2::new( - glyph.rect.max.x + positions_diff[2].x, - glyph.rect.max.y + positions_diff[2].y, - ), - Vec2::new( - glyph.rect.min.x + positions_diff[3].x, - glyph.rect.max.y + positions_diff[3].y, - ), - ] - .map(|pos| pos / atlas_extent); - - for i in 0..4 { + for vertex in &vertices { ui_meta.vertices.push(UiVertex { - position: positions_clipped[i].into(), - uv: uvs[i].into(), + position: vertex.0.extend(0.).into(), + uv: vertex.1.into(), color, - flags: shader_flags::TEXTURED | shader_flags::CORNERS[i], + flags: shader_flags::TEXTURED, radius: [0.0; 4], border: [0.0; 4], size: rect_size.into(), @@ -1955,12 +1834,14 @@ pub fn prepare_uinodes( }); } - for &i in &QUAD_INDICES { - ui_meta.indices.push(indices_index + i as u32); + for i in 1..vertices.len() as u32 - 1 { + ui_meta.indices.push(indices_index); + ui_meta.indices.push(indices_index + i); + ui_meta.indices.push(indices_index + i + 1); } - vertices_index += 6; - indices_index += 4; + vertices_index += 3 * (vertices.len() as u32 - 2); + indices_index += vertices.len() as u32; } } } diff --git a/crates/bevy_ui_render/src/text.rs b/crates/bevy_ui_render/src/text.rs index 86b0c9813983d..b211729bb454d 100644 --- a/crates/bevy_ui_render/src/text.rs +++ b/crates/bevy_ui_render/src/text.rs @@ -16,6 +16,24 @@ use crate::{ stack_z_offsets, ExtractedUiItem, ExtractedUiNode, ExtractedUiNodes, NodeType, UiCameraMap, }; +pub(crate) fn calculate_text_scroll_clip( + text_scroll: Option<&TextScroll>, + maybe_clip: Option<&CalculatedClip>, + uinode: &ComputedNode, + global_transform: &UiGlobalTransform, +) -> Option { + if text_scroll.is_some() { + Some( + maybe_clip + .cloned() + .unwrap_or_default() + .with_rect(uinode.content_box(), global_transform), + ) + } else { + maybe_clip.cloned() + } +} + pub fn extract_text_cursor( mut commands: Commands, mut extracted_uinodes: ResMut, @@ -65,16 +83,7 @@ pub fn extract_text_cursor( uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), ); - let clip = if text_scroll.is_some() { - let content_box = uinode.content_box(); - let text_clip = Rect::from_center_size( - global_transform.affine().translation + content_box.center(), - content_box.size(), - ); - Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) - } else { - maybe_clip.map(|clip| clip.clip) - }; + let clip = calculate_text_scroll_clip(text_scroll, maybe_clip, uinode, global_transform); let mut focused = false; @@ -97,7 +106,7 @@ pub fn extract_text_cursor( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_SELECTION, - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(selection.center()), @@ -126,7 +135,7 @@ pub fn extract_text_cursor( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_CURSOR, - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(cursor_rect.center()), @@ -202,16 +211,7 @@ pub fn extract_preedit_underlines( uinode.content_box().min - text_scroll.map_or(Vec2::ZERO, |s| s.0), ); - let clip = if text_scroll.is_some() { - let content_box = uinode.content_box(); - let text_clip = Rect::from_center_size( - global_transform.affine().translation + content_box.center(), - content_box.size(), - ); - Some(maybe_clip.map_or(text_clip, |clip| clip.clip.intersect(text_clip))) - } else { - maybe_clip.map(|clip| clip.clip) - }; + let clip = calculate_text_scroll_clip(text_scroll, maybe_clip, uinode, global_transform); let color = text_color.0.to_linear(); @@ -219,7 +219,7 @@ pub fn extract_preedit_underlines( extracted_uinodes.uinodes.push(ExtractedUiNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), z_order: stack_index.0 as f32 + stack_z_offsets::TEXT_STRIKETHROUGH, - clip, + clip: clip.clone(), image: AssetId::default(), extracted_camera_entity, transform: transform * Affine2::from_translation(rect.center()), diff --git a/crates/bevy_ui_render/src/ui_material_pipeline.rs b/crates/bevy_ui_render/src/ui_material_pipeline.rs index e0f17d64fb262..d65496452fddb 100644 --- a/crates/bevy_ui_render/src/ui_material_pipeline.rs +++ b/crates/bevy_ui_render/src/ui_material_pipeline.rs @@ -1,3 +1,4 @@ +use crate::clipping::clip_polygon; use crate::ui_material::{MaterialNode, UiMaterial, UiMaterialKey}; use crate::*; use bevy_asset::*; @@ -296,7 +297,7 @@ pub struct ExtractedUiMaterialNode { pub border: BorderRect, pub border_radius: [f32; 4], pub material: AssetId, - pub clip: Option, + pub clip: Option, // Camera to render this UI node to. By the time it is extracted, // it is defaulted to a single camera if only one exists. // Nodes with ambiguous camera will be ignored. @@ -374,7 +375,7 @@ pub fn extract_ui_material_nodes( }, border: computed_node.border(), border_radius: computed_node.border_radius().into(), - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), extracted_camera_entity, main_entity: entity.into(), }); @@ -441,98 +442,51 @@ pub fn prepare_uimaterial_nodes( let rect_size = uinode_rect.size(); - let positions = QUAD_VERTEX_POSITIONS.map(|pos| { - extracted_uinode - .transform - .transform_point2(pos * rect_size) - .extend(1.0) - }); - - let positions_diff = if let Some(clip) = extracted_uinode.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 positions = QUAD_VERTEX_POSITIONS + .map(|pos| extracted_uinode.transform.transform_point2(pos * rect_size)); - let transformed_rect_size = extracted_uinode - .transform - .transform_vector2(rect_size) - .abs(); - - // 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 extracted_uinode.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 uvs = [ - Vec2::new( - uinode_rect.min.x + positions_diff[0].x, - uinode_rect.min.y + positions_diff[0].y, - ), - Vec2::new( - uinode_rect.max.x + positions_diff[1].x, - uinode_rect.min.y + positions_diff[1].y, - ), - Vec2::new( - uinode_rect.max.x + positions_diff[2].x, - uinode_rect.max.y + positions_diff[2].y, - ), - Vec2::new( - uinode_rect.min.x + positions_diff[3].x, - uinode_rect.max.y + positions_diff[3].y, - ), + Vec2::new(uinode_rect.min.x, uinode_rect.min.y), + Vec2::new(uinode_rect.max.x, uinode_rect.min.y), + Vec2::new(uinode_rect.max.x, uinode_rect.max.y), + Vec2::new(uinode_rect.min.x, uinode_rect.max.y), ] .map(|pos| pos / uinode_rect.max); - for i in QUAD_INDICES { - ui_meta.vertices.push(UiMaterialVertex { - position: positions_clipped[i].into(), - uv: uvs[i].into(), - size: extracted_uinode.rect.size().into(), - radius: extracted_uinode.border_radius, - border: [ - extracted_uinode.border.min_inset.x, - extracted_uinode.border.min_inset.y, - extracted_uinode.border.max_inset.x, - extracted_uinode.border.max_inset.y, - ], - }); + let polygon = [ + (positions[0], uvs[0]), + (positions[1], uvs[1]), + (positions[2], uvs[2]), + (positions[3], uvs[3]), + ]; + let clipped_polygon = + clip_polygon(extracted_uinode.clip.as_ref(), &polygon, Vec2::lerp); + if clipped_polygon.is_empty() { + continue; + } + + for i in 1..clipped_polygon.len() - 1 { + for vertex in [ + clipped_polygon[0], + clipped_polygon[i], + clipped_polygon[i + 1], + ] { + ui_meta.vertices.push(UiMaterialVertex { + position: vertex.0.extend(1.0).into(), + uv: vertex.1.into(), + size: extracted_uinode.rect.size().into(), + radius: extracted_uinode.border_radius, + border: [ + extracted_uinode.border.min_inset.x, + extracted_uinode.border.min_inset.y, + extracted_uinode.border.max_inset.x, + extracted_uinode.border.max_inset.y, + ], + }); + } } - index += QUAD_INDICES.len() as u32; + index += 3 * (clipped_polygon.len() as u32 - 2); existing_batch.unwrap().1.range.end = index; ui_phase.items[batch_item_index].batch_range_mut().end += 1; } else { diff --git a/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs b/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs index 7f8240686574d..5d5ea03b47530 100644 --- a/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs +++ b/crates/bevy_ui_render/src/ui_texture_slice_pipeline.rs @@ -1,5 +1,6 @@ use core::{hash::Hash, ops::Range}; +use crate::clipping::clip_polygon; use crate::*; use bevy_asset::*; use bevy_color::{ColorToComponents, LinearRgba}; @@ -196,7 +197,7 @@ pub struct ExtractedUiTextureSlice { pub rect: Rect, pub atlas_rect: Option, pub image: AssetId, - pub clip: Option, + pub clip: Option, pub extracted_camera_entity: Entity, pub color: LinearRgba, pub image_scale_mode: SpriteImageMode, @@ -294,7 +295,7 @@ pub fn extract_ui_texture_slices( min: Vec2::ZERO, max: visual_box.size(), }, - clip: clip.map(|clip| clip.clip), + clip: clip.cloned(), image: image.image.id(), extracted_camera_entity, image_scale_mode, @@ -488,89 +489,10 @@ pub fn prepare_ui_slices( let rect_size = uinode_rect.size(); // Specify the corners of the node - let positions = QUAD_VERTEX_POSITIONS.map(|pos| { - (texture_slices.transform.transform_point2(pos * rect_size)).extend(0.) - }); - - // 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) = texture_slices.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 = QUAD_VERTEX_POSITIONS + .map(|pos| texture_slices.transform.transform_point2(pos * rect_size)); - 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 = - texture_slices.transform.transform_vector2(rect_size).abs(); - - // 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 texture_slices.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 flags = if texture_slices.image != AssetId::default() { - shader_flags::TEXTURED - } else { - shader_flags::UNTEXTURED - }; - - let uvs = if flags == shader_flags::UNTEXTURED { - [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y] - } else { - let atlas_extent = uinode_rect.max; - [ - Vec2::new( - uinode_rect.min.x + positions_diff[0].x, - uinode_rect.min.y + positions_diff[0].y, - ), - Vec2::new( - uinode_rect.max.x + positions_diff[1].x, - uinode_rect.min.y + positions_diff[1].y, - ), - Vec2::new( - uinode_rect.max.x + positions_diff[2].x, - uinode_rect.max.y + positions_diff[2].y, - ), - Vec2::new( - uinode_rect.min.x + positions_diff[3].x, - uinode_rect.max.y + positions_diff[3].y, - ), - ] - .map(|pos| pos / atlas_extent) - }; + let uvs = [Vec2::ZERO, Vec2::X, Vec2::ONE, Vec2::Y]; let color = texture_slices.color.to_f32_array(); @@ -602,10 +524,24 @@ pub fn prepare_ui_slices( &texture_slices.image_scale_mode, ); - for i in 0..4 { + let vertices = clip_polygon( + texture_slices.clip.as_ref(), + &[ + (positions[0], uvs[0]), + (positions[1], uvs[1]), + (positions[2], uvs[2]), + (positions[3], uvs[3]), + ], + Vec2::lerp, + ); + if vertices.is_empty() { + continue; + } + + for vertex in &vertices { ui_meta.vertices.push(UiTextureSliceVertex { - position: positions_clipped[i].into(), - uv: uvs[i].into(), + position: vertex.0.extend(0.).into(), + uv: vertex.1.into(), color, slices, border, @@ -614,12 +550,14 @@ pub fn prepare_ui_slices( }); } - for &i in &QUAD_INDICES { - ui_meta.indices.push(indices_index + i as u32); + for i in 1..vertices.len() as u32 - 1 { + ui_meta.indices.push(indices_index); + ui_meta.indices.push(indices_index + i); + ui_meta.indices.push(indices_index + i + 1); } - vertices_index += 6; - indices_index += 4; + vertices_index += 3 * (vertices.len() as u32 - 2); + indices_index += vertices.len() as u32; existing_batch.unwrap().1.range.end = vertices_index; ui_phase.items[batch_item_index].batch_range_mut().end += 1; diff --git a/examples/README.md b/examples/README.md index 0871132574246..f538086b4d245 100644 --- a/examples/README.md +++ b/examples/README.md @@ -616,6 +616,7 @@ Example | Description [Multiple Text Inputs](../examples/ui/text/multiple_text_inputs.rs) | Demonstrates multiple text inputs [Overflow](../examples/ui/scroll_and_overflow/overflow.rs) | Simple example demonstrating overflow behavior [Overflow Clip Margin](../examples/ui/scroll_and_overflow/overflow_clip_margin.rs) | Simple example demonstrating the OverflowClipMargin style property +[Overflow Transform](../examples/ui/scroll_and_overflow/overflow_transform.rs) | Demonstrates nested transformed UI clipping [Overflow and Clipping Debug](../examples/ui/scroll_and_overflow/overflow_debug.rs) | An example to debug overflow and clipping behavior [Relative Cursor Position](../examples/ui/relative_cursor_position.rs) | Showcases the RelativeCursorPosition component [Render UI to Texture](../examples/ui/render_ui_to_texture.rs) | An example of rendering UI as a part of a 3D world diff --git a/examples/ui/scroll_and_overflow/overflow_transform.rs b/examples/ui/scroll_and_overflow/overflow_transform.rs new file mode 100644 index 0000000000000..4b159938337ae --- /dev/null +++ b/examples/ui/scroll_and_overflow/overflow_transform.rs @@ -0,0 +1,133 @@ +//! Demonstrates nested transformed UI clipping. + +use bevy::color::palettes::css::NAVY; +use bevy::math::ops::sin; +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, (rotate_nodes, scale_inner)) + .run(); +} + +#[derive(Component)] +struct RotatingClipLayer { + base_rotation: f32, + speed: f32, +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2d); + + commands + .spawn(Node { + width: percent(100), + height: percent(100), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }) + .with_children(|parent| { + parent + .spawn(rotating_node( + 400., + 0.35, + 0.18, + Color::srgb(0.12, 0.17, 0.22), + )) + .with_children(|parent| { + parent + .spawn(rotating_node( + 350., + -0.5, + -0.4, + Color::srgb(0.24, 0.18, 0.32), + )) + .with_children(|parent| { + parent + .spawn(rotating_node( + 300., + 0.65, + 0.55, + Color::srgb(0.15, 0.30, 0.25), + )) + .with_children(|parent| { + parent.spawn(( + Node { + flex_direction: FlexDirection::Column, + position_type: PositionType::Absolute, + margin: auto().all(), + border_radius: BorderRadius::all(percent(20.)), + align_items: AlignItems::Center, + ..default() + }, + InnerNode, + BackgroundColor(NAVY.into()), + UiTransform::from_rotation(Rot2::degrees(45.)), + children![ + ( + ImageNode::new( + asset_server + .load("branding/bevy_logo_dark_big.png"), + ), + Node { + width: px(400), + ..default() + }, + ), + ( + Text::new("transform + overflow"), + TextFont { + font: asset_server + .load("fonts/FiraSans-Bold.ttf") + .into(), + font_size: FontSize::Px(34.), + ..default() + }, + TextColor(Color::WHITE), + ) + ], + )); + }); + }); + }); + }); +} + +fn rotate_nodes(time: Res