Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
28bd27c
New UI clipping implementation.
ickshonpe May 6, 2026
d07aefe
Merge branch 'main' into overflow-transform
ickshonpe May 6, 2026
4c5794b
Update _release-content/migration-guides/calculated_clip_rects.md
ickshonpe May 6, 2026
01ea5dd
fix migration guide
ickshonpe May 6, 2026
218dfb5
Merge branch 'overflow-transform' of https://github.com/ickshonpe/bev…
ickshonpe May 6, 2026
cac33ca
formatting
ickshonpe May 6, 2026
27b200d
Merge branch 'main' into overflow-transform
ickshonpe May 6, 2026
a9dea65
Merge branch 'main' into overflow-transform
ickshonpe May 7, 2026
1c4dc92
replaced map_or with is_none_or
ickshonpe May 7, 2026
38beb0b
removed redundant closure
ickshonpe May 7, 2026
5ba6143
Removed unused shader flags logic from ui_texture_slice_pipeline
ickshonpe May 7, 2026
8cb84ff
fixed doc link
ickshonpe May 7, 2026
71e4091
removed second scale factor multiplication in resolve_clip_rect
ickshonpe May 7, 2026
4c409b3
Merge branch 'main' into overflow-transform
ickshonpe May 7, 2026
ca366f1
use bevy::math sin
ickshonpe May 7, 2026
0f81c5a
Merge branch 'overflow-transform' of https://github.com/ickshonpe/bev…
ickshonpe May 7, 2026
7e884fe
Merge branch 'main' into overflow-transform
ickshonpe May 8, 2026
8442a87
iter edges
ickshonpe May 8, 2026
4b97269
removed distance function
ickshonpe May 8, 2026
778e9f9
updated clip_polygon's doc comments
ickshonpe May 8, 2026
f37cd2c
Added arguments to do comments and INLINE_CAPACITY const
ickshonpe May 8, 2026
362e2b0
Merge branch 'main' into overflow-transform
ickshonpe May 8, 2026
bdf1d45
Added calculate_text_scroll_clip helper fn
ickshonpe May 8, 2026
9a08544
Merge branch 'overflow-transform' of https://github.com/ickshonpe/bev…
ickshonpe May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions _release-content/migration-guides/calculated_clip_rects.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 6 additions & 33 deletions crates/bevy_ui/src/focus.rs
Original file line number Diff line number Diff line change
@@ -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},
};
Expand Down Expand Up @@ -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
Expand All @@ -155,8 +154,6 @@ pub fn ui_focus_system(
touches_input: Res<Touches>,
ui_stack: Res<UiStack>,
mut node_query: Query<NodeQuery>,
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
child_of_query: Query<&ChildOf, Without<OverrideClip>>,
) {
let primary_window = primary_window.iter().next();

Expand Down Expand Up @@ -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
.map_or(true, |clip| clip.contains_point(*point))
Comment thread
ickshonpe marked this conversation as resolved.
Outdated
});

// The mouse position relative to the node
Expand Down Expand Up @@ -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<OverrideClip>>,
) -> 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
}
14 changes: 5 additions & 9 deletions crates/bevy_ui/src/picking_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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.
Expand All @@ -106,8 +107,6 @@ pub fn ui_picking(
ui_stack: Res<UiStack>,
node_query: Query<NodeQuery>,
mut output: MessageWriter<PointerHits>,
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
child_of_query: Query<&ChildOf, Without<OverrideClip>>,
pickable_query: Query<&Pickable>,
) {
// Map from each camera to its active pointers and their positions in viewport space
Expand Down Expand Up @@ -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
.map_or(true, |clip| clip.contains_point(*cursor_position))
Comment thread
ickshonpe marked this conversation as resolved.
Outdated
&& let Some(target) = node
.text_node
.and_then(|(text_layout_info, text_block)| {
Expand Down
97 changes: 90 additions & 7 deletions crates/bevy_ui/src/ui_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -270,6 +270,9 @@ impl ComputedNode {
clip_rect.min += clip_inset.min_inset;
clip_rect.max -= clip_inset.max_inset;

clip_rect =
clip_rect.inflate(overflow_clip_margin.margin.max(0.) / self.inverse_scale_factor);

if overflow.x == OverflowAxis::Visible {
clip_rect.min.x = -f32::INFINITY;
clip_rect.max.x = f32::INFINITY;
Expand Down Expand Up @@ -2390,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,
Expand Down
75 changes: 30 additions & 45 deletions crates/bevy_ui/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
experimental::{UiChildren, UiRootNodes},
ui_transform::UiGlobalTransform,
CalculatedClip, ComputedUiRenderTargetInfo, ComputedUiTargetCamera, DefaultUiCamera, Display,
Node, OverflowAxis, OverrideClip, UiScale, UiTargetCamera,
Node, OverrideClip, UiScale, UiTargetCamera,
};

use super::ComputedNode;
Expand All @@ -15,8 +15,7 @@ use bevy_ecs::{
query::Has,
system::{Commands, Query, Res},
};
use bevy_math::{Rect, UVec2};
use bevy_sprite::BorderRect;
use bevy_math::UVec2;

/// Updates clipping for all nodes
pub fn update_clipping_system(
Expand Down Expand Up @@ -53,7 +52,7 @@ fn update_clipping(
Has<OverrideClip>,
)>,
entity: Entity,
mut maybe_inherited_clip: Option<Rect>,
mut maybe_inherited_clip: Option<CalculatedClip>,
) {
let Ok((node, computed_node, transform, maybe_calculated_clip, has_override_clip)) =
node_query.get_mut(entity)
Expand All @@ -66,68 +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::<CalculatedClip>();
}
} 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(|clip| clip.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
let mut clip_rect = Rect::from_center_size(transform.translation, computed_node.size());

// 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 clip_inset = match node.overflow_clip_margin.visual_box {
crate::OverflowClipBox::BorderBox => BorderRect::ZERO,
crate::OverflowClipBox::ContentBox => computed_node.content_inset(),
crate::OverflowClipBox::PaddingBox => computed_node.border(),
};

clip_rect.min += clip_inset.min_inset;
clip_rect.max -= clip_inset.max_inset;

clip_rect = clip_rect
.inflate(node.overflow_clip_margin.margin.max(0.) / computed_node.inverse_scale_factor);

if node.overflow.x == OverflowAxis::Visible {
clip_rect.min.x = -f32::INFINITY;
clip_rect.max.x = f32::INFINITY;
}
if node.overflow.y == OverflowAxis::Visible {
clip_rect.min.y = -f32::INFINITY;
clip_rect.max.y = f32::INFINITY;
}
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(),
);
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/bevy_ui_render/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading