Implement the Overflow::Hidden style property for UI (#3296)

# Objective

This PR implements the `overflow` style property in `bevy_ui`. When set to `Overflow::Hidden`, the children of that node are clipped so that overflowing parts are not rendered. This is an important building block for UI widgets.

## Solution

Clipping is done on the CPU so that it does not break batching.

The clip regions update was implemented as a separate system for clarity, but it could be merged with the other UI systems to avoid doing an additional tree traversal. (I don't think it's important until we fix the layout performance issues though).

A scrolling list was added to the `ui_pipelined` example to showcase `Overflow::Hidden`. For the sake of simplicity, it can only be scrolled with a mouse.
This commit is contained in:
davier 2021-12-19 05:44:28 +00:00
parent 9a89295a17
commit 340957994d
6 changed files with 319 additions and 58 deletions

View File

@ -1,9 +1,10 @@
use bevy_math::Vec2;
use bevy_reflect::Reflect;
/// A rectangle defined by two points. There is no defined origin, so 0,0 could be anywhere
/// (top-left, bottom-left, etc)
#[repr(C)]
#[derive(Default, Clone, Copy, Debug)]
#[derive(Default, Clone, Copy, Debug, Reflect)]
pub struct Rect {
/// The beginning point of the rect
pub min: Vec2,

View File

@ -26,7 +26,7 @@ use bevy_ecs::schedule::{ParallelSystemDescriptorCoercion, SystemLabel};
use bevy_input::InputSystem;
use bevy_math::{Rect, Size};
use bevy_transform::TransformSystem;
use update::ui_z_system;
use update::{ui_z_system, update_clipping_system};
#[derive(Default)]
pub struct UiPlugin;
@ -89,6 +89,10 @@ impl Plugin for UiPlugin {
ui_z_system
.after(UiSystem::Flex)
.before(TransformSystem::TransformPropagate),
)
.add_system_to_stage(
CoreStage::PostUpdate,
update_clipping_system.after(TransformSystem::TransformPropagate),
);
crate::render::build_ui_render(app);

View File

@ -26,7 +26,7 @@ use bevy_render::{
view::ViewUniforms,
RenderApp, RenderStage, RenderWorld,
};
use bevy_sprite::{SpriteAssetEvents, TextureAtlas};
use bevy_sprite::{Rect, SpriteAssetEvents, TextureAtlas};
use bevy_text::{DefaultTextPipeline, Text};
use bevy_transform::components::GlobalTransform;
use bevy_utils::HashMap;
@ -34,7 +34,7 @@ use bevy_window::Windows;
use bytemuck::{Pod, Zeroable};
use crate::{Node, UiColor, UiImage};
use crate::{CalculatedClip, Node, UiColor, UiImage};
pub mod node {
pub const UI_PASS_DRIVER: &str = "ui_pass_driver";
@ -120,9 +120,10 @@ pub fn build_ui_render(app: &mut App) {
pub struct ExtractedUiNode {
pub transform: Mat4,
pub color: Color,
pub rect: bevy_sprite::Rect,
pub rect: Rect,
pub image: Handle<Image>,
pub atlas_size: Option<Vec2>,
pub clip: Option<Rect>,
}
#[derive(Default)]
@ -133,11 +134,17 @@ pub struct ExtractedUiNodes {
pub fn extract_uinodes(
mut render_world: ResMut<RenderWorld>,
images: Res<Assets<Image>>,
uinode_query: Query<(&Node, &GlobalTransform, &UiColor, &UiImage)>,
uinode_query: Query<(
&Node,
&GlobalTransform,
&UiColor,
&UiImage,
Option<&CalculatedClip>,
)>,
) {
let mut extracted_uinodes = render_world.get_resource_mut::<ExtractedUiNodes>().unwrap();
extracted_uinodes.uinodes.clear();
for (uinode, transform, color, image) in uinode_query.iter() {
for (uinode, transform, color, image, clip) in uinode_query.iter() {
let image = image.0.clone_weak();
// Skip loading images
if !images.contains(image.clone_weak()) {
@ -152,6 +159,7 @@ pub fn extract_uinodes(
},
image,
atlas_size: None,
clip: clip.map(|clip| clip.clip),
});
}
}
@ -161,7 +169,13 @@ pub fn extract_text_uinodes(
texture_atlases: Res<Assets<TextureAtlas>>,
text_pipeline: Res<DefaultTextPipeline>,
windows: Res<Windows>,
uinode_query: Query<(Entity, &Node, &GlobalTransform, &Text)>,
uinode_query: Query<(
Entity,
&Node,
&GlobalTransform,
&Text,
Option<&CalculatedClip>,
)>,
) {
let mut extracted_uinodes = render_world.get_resource_mut::<ExtractedUiNodes>().unwrap();
@ -171,7 +185,7 @@ pub fn extract_text_uinodes(
1.
};
for (entity, uinode, transform, text) in uinode_query.iter() {
for (entity, uinode, transform, text, clip) in uinode_query.iter() {
// Skip if size is set to zero (e.g. when a parent is set to `Display::None`)
if uinode.size == Vec2::ZERO {
continue;
@ -203,6 +217,7 @@ pub fn extract_text_uinodes(
rect,
image: texture,
atlas_size,
clip: clip.map(|clip| clip.clip),
});
}
}
@ -231,15 +246,15 @@ impl Default for UiMeta {
}
}
const QUAD_VERTEX_POSITIONS: &[Vec3] = &[
const_vec3!([-0.5, -0.5, 0.0]),
const_vec3!([0.5, 0.5, 0.0]),
const_vec3!([-0.5, 0.5, 0.0]),
const QUAD_VERTEX_POSITIONS: [Vec3; 4] = [
const_vec3!([-0.5, -0.5, 0.0]),
const_vec3!([0.5, -0.5, 0.0]),
const_vec3!([0.5, 0.5, 0.0]),
const_vec3!([-0.5, 0.5, 0.0]),
];
const QUAD_INDICES: [usize; 6] = [0, 2, 3, 0, 1, 2];
#[derive(Component)]
pub struct UiBatch {
pub range: Range<u32>,
@ -279,47 +294,90 @@ pub fn prepare_uinodes(
}
let uinode_rect = extracted_uinode.rect;
let rect_size = uinode_rect.size().extend(1.0);
// Specify the corners of the node
let mut bottom_left = Vec2::new(uinode_rect.min.x, uinode_rect.max.y);
let mut top_left = uinode_rect.min;
let mut top_right = Vec2::new(uinode_rect.max.x, uinode_rect.min.y);
let mut bottom_right = uinode_rect.max;
let positions = QUAD_VERTEX_POSITIONS
.map(|pos| (extracted_uinode.transform * (pos * rect_size).extend(1.)).xyz());
let atlas_extent = extracted_uinode.atlas_size.unwrap_or(uinode_rect.max);
bottom_left /= atlas_extent;
bottom_right /= atlas_extent;
top_left /= atlas_extent;
top_right /= atlas_extent;
// 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) = 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 uvs: [[f32; 2]; 6] = [
bottom_left.into(),
top_right.into(),
top_left.into(),
bottom_left.into(),
bottom_right.into(),
top_right.into(),
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 rect_size = extracted_uinode.rect.size().extend(1.0);
// Cull nodes that are completely clipped
if positions_diff[0].x - positions_diff[1].x >= rect_size.x
|| positions_diff[1].y - positions_diff[2].y >= rect_size.y
{
continue;
}
// Clip UVs (Note: y is reversed in UV space)
let atlas_extent = extracted_uinode.atlas_size.unwrap_or(uinode_rect.max);
let uvs = [
Vec2::new(
uinode_rect.min.x + positions_diff[0].x,
uinode_rect.max.y - positions_diff[0].y,
),
Vec2::new(
uinode_rect.max.x + positions_diff[1].x,
uinode_rect.max.y - positions_diff[1].y,
),
Vec2::new(
uinode_rect.max.x + positions_diff[2].x,
uinode_rect.min.y - positions_diff[2].y,
),
Vec2::new(
uinode_rect.min.x + positions_diff[3].x,
uinode_rect.min.y - positions_diff[3].y,
),
]
.map(|pos| pos / atlas_extent);
let color = extracted_uinode.color.as_linear_rgba_f32();
// encode color as a single u32 to save space
let color = (color[0] * 255.0) as u32
| ((color[1] * 255.0) as u32) << 8
| ((color[2] * 255.0) as u32) << 16
| ((color[3] * 255.0) as u32) << 24;
for (index, vertex_position) in QUAD_VERTEX_POSITIONS.iter().enumerate() {
let mut final_position = *vertex_position * rect_size;
final_position = (extracted_uinode.transform * final_position.extend(1.0)).xyz();
for i in QUAD_INDICES {
ui_meta.vertices.push(UiVertex {
position: final_position.into(),
uv: uvs[index],
position: positions_clipped[i].into(),
uv: uvs[i].into(),
color,
});
}
last_z = extracted_uinode.transform.w_axis[2];
end += QUAD_VERTEX_POSITIONS.len() as u32;
end += QUAD_INDICES.len() as u32;
}
// if start != end, there is one last batch to process

View File

@ -76,6 +76,7 @@ pub struct Style {
pub min_size: Size<Val>,
pub max_size: Size<Val>,
pub aspect_ratio: Option<f32>,
pub overflow: Overflow,
}
impl Default for Style {
@ -101,6 +102,7 @@ impl Default for Style {
min_size: Size::new(Val::Auto, Val::Auto),
max_size: Size::new(Val::Auto, Val::Auto),
aspect_ratio: Default::default(),
overflow: Default::default(),
}
}
}
@ -214,19 +216,19 @@ impl Default for JustifyContent {
}
}
// TODO: add support for overflow settings
// #[derive(Copy, Clone, PartialEq, Debug)]
// pub enum Overflow {
// Visible,
// Hidden,
// Scroll,
// }
#[derive(Copy, Clone, PartialEq, Debug, Reflect, Serialize, Deserialize)]
#[reflect_value(PartialEq, Serialize, Deserialize)]
pub enum Overflow {
Visible,
Hidden,
// Scroll,
}
// impl Default for Overflow {
// fn default() -> Overflow {
// Overflow::Visible
// }
// }
impl Default for Overflow {
fn default() -> Overflow {
Overflow::Visible
}
}
#[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize, Reflect)]
#[reflect_value(PartialEq, Serialize, Deserialize)]
@ -286,3 +288,9 @@ impl From<Handle<Image>> for UiImage {
Self(handle)
}
}
#[derive(Component, Default, Copy, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct CalculatedClip {
pub clip: bevy_sprite::Rect,
}

View File

@ -1,10 +1,17 @@
use crate::{CalculatedClip, Overflow, Style};
use super::Node;
use bevy_ecs::{
entity::Entity,
query::{With, Without},
system::Query,
system::{Commands, Query},
};
use bevy_math::Vec2;
use bevy_sprite::Rect;
use bevy_transform::{
components::GlobalTransform,
prelude::{Children, Parent, Transform},
};
use bevy_transform::prelude::{Children, Parent, Transform};
pub const UI_Z_STEP: f32 = 0.001;
@ -50,6 +57,73 @@ fn update_hierarchy(
}
current_global_z
}
pub fn update_clipping_system(
mut commands: Commands,
root_node_query: Query<Entity, (With<Node>, Without<Parent>)>,
mut node_query: Query<(&Node, &GlobalTransform, &Style, Option<&mut CalculatedClip>)>,
children_query: Query<&Children>,
) {
for root_node in root_node_query.iter() {
update_clipping(
&mut commands,
&children_query,
&mut node_query,
root_node,
None,
)
}
}
fn update_clipping(
commands: &mut Commands,
children_query: &Query<&Children>,
node_query: &mut Query<(&Node, &GlobalTransform, &Style, Option<&mut CalculatedClip>)>,
entity: Entity,
clip: Option<Rect>,
) {
let (node, global_transform, style, calculated_clip) = node_query.get_mut(entity).unwrap();
// Update this node's CalculatedClip component
match (clip, calculated_clip) {
(None, None) => {}
(None, Some(_)) => {
commands.entity(entity).remove::<CalculatedClip>();
}
(Some(clip), None) => {
commands.entity(entity).insert(CalculatedClip { clip });
}
(Some(clip), Some(mut old_clip)) => {
*old_clip = CalculatedClip { clip };
}
}
// Calculate new clip for its children
let children_clip = match style.overflow {
Overflow::Visible => clip,
Overflow::Hidden => {
let node_center = global_transform.translation.truncate();
let node_rect = Rect {
min: node_center - node.size / 2.,
max: node_center + node.size / 2.,
};
if let Some(clip) = clip {
Some(Rect {
min: Vec2::max(clip.min, node_rect.min),
max: Vec2::min(clip.max, node_rect.max),
})
} else {
Some(node_rect)
}
}
};
if let Ok(children) = children_query.get(entity) {
for child in children.iter().cloned() {
update_clipping(commands, children_query, node_query, child, children_clip);
}
}
}
#[cfg(test)]
mod tests {
use bevy_ecs::{

View File

@ -1,10 +1,14 @@
use bevy::prelude::*;
use bevy::{
input::mouse::{MouseScrollUnit, MouseWheel},
prelude::*,
};
/// This example illustrates the various features of Bevy UI.
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(setup)
.add_system(mouse_scroll)
.run();
}
@ -68,14 +72,97 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
});
});
// right vertical fill
parent.spawn_bundle(NodeBundle {
style: Style {
size: Size::new(Val::Px(200.0), Val::Percent(100.0)),
parent
.spawn_bundle(NodeBundle {
style: Style {
flex_direction: FlexDirection::ColumnReverse,
justify_content: JustifyContent::Center,
size: Size::new(Val::Px(200.0), Val::Percent(100.0)),
..Default::default()
},
color: Color::rgb(0.15, 0.15, 0.15).into(),
..Default::default()
},
color: Color::rgb(0.15, 0.15, 0.15).into(),
..Default::default()
});
})
.with_children(|parent| {
// Title
parent.spawn_bundle(TextBundle {
style: Style {
size: Size::new(Val::Undefined, Val::Px(25.)),
margin: Rect {
left: Val::Auto,
right: Val::Auto,
..Default::default()
},
..Default::default()
},
text: Text::with_section(
"Scrolling list",
TextStyle {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 25.,
color: Color::WHITE,
},
Default::default(),
),
..Default::default()
});
// List with hidden overflow
parent
.spawn_bundle(NodeBundle {
style: Style {
flex_direction: FlexDirection::ColumnReverse,
align_self: AlignSelf::Center,
size: Size::new(Val::Percent(100.0), Val::Percent(50.0)),
overflow: Overflow::Hidden,
..Default::default()
},
color: Color::rgb(0.10, 0.10, 0.10).into(),
..Default::default()
})
.with_children(|parent| {
// Moving panel
parent
.spawn_bundle(NodeBundle {
style: Style {
flex_direction: FlexDirection::ColumnReverse,
flex_grow: 1.0,
max_size: Size::new(Val::Undefined, Val::Undefined),
..Default::default()
},
color: Color::NONE.into(),
..Default::default()
})
.insert(ScrollingList::default())
.with_children(|parent| {
// List items
for i in 0..30 {
parent.spawn_bundle(TextBundle {
style: Style {
flex_shrink: 0.,
size: Size::new(Val::Undefined, Val::Px(20.)),
margin: Rect {
left: Val::Auto,
right: Val::Auto,
..Default::default()
},
..Default::default()
},
text: Text::with_section(
format!("Item {}", i),
TextStyle {
font: asset_server
.load("fonts/FiraSans-Bold.ttf"),
font_size: 20.,
color: Color::WHITE,
},
Default::default(),
),
..Default::default()
});
}
});
});
});
// absolute positioning
parent
.spawn_bundle(NodeBundle {
@ -212,3 +299,32 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
});
});
}
#[derive(Component, Default)]
struct ScrollingList {
position: f32,
}
fn mouse_scroll(
mut mouse_wheel_events: EventReader<MouseWheel>,
mut query_list: Query<(&mut ScrollingList, &mut Style, &Children, &Node)>,
query_item: Query<&Node>,
) {
for mouse_wheel_event in mouse_wheel_events.iter() {
for (mut scrolling_list, mut style, children, uinode) in query_list.iter_mut() {
let items_height: f32 = children
.iter()
.map(|entity| query_item.get(*entity).unwrap().size.y)
.sum();
let panel_height = uinode.size.y;
let max_scroll = (items_height - panel_height).max(0.);
let dy = match mouse_wheel_event.unit {
MouseScrollUnit::Line => mouse_wheel_event.y * 20.,
MouseScrollUnit::Pixel => mouse_wheel_event.y,
};
scrolling_list.position += dy;
scrolling_list.position = scrolling_list.position.clamp(-max_scroll, 0.);
style.position.top = Val::Px(scrolling_list.position);
}
}
}