How do Nice UI in Bevy?!?
·Bevy, a popular game engine written in the Rust programming language, uses a very capable low level UI layout engine called taffy. Taffy provides Bevy with a facility for defining the relative positions, sizes, and behavior of UI boxes using a CSS style flex-box model. Bevy does not yet provide a high level interface for authoring UI or any kind of layout editor.
To make things more complicated, Bevy also doesn't provide a built-in data-driven way to describe UI. This means that anyone wanting to build UI in Bevy needs to be familiar with the flex-box model, be willing to fiddle with layouts at run-time to get good looking results, and possibly write their own tools.
Bevy does not yet provide an ergonomic API for creating and managing parent-child entity relationships. This is a problem, because UIs are basically complex trees or graphs, with widgets always having parent-child relationships and quite frequently having other kinds of relations as well.
Raw Bevy UI code gets complicated very quickly.
Here's an example from Bevy's own "button" example:
commands
.spawn(NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
..default()
})
.with_children(|parent| {
parent
.spawn(ButtonBundle {
style: Style {
width: Val::Px(150.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
// horizontally center child text
justify_content: JustifyContent::Center,
// vertically center child text
align_items: AlignItems::Center,
..default()
},
border_color: BorderColor(Color::BLACK),
image: UiImage::default().with_color(NORMAL_BUTTON),
..default()
})
.with_children(|parent| {
parent.spawn(TextBundle::from_section(
"Button",
TextStyle {
font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 40.0,
color: Color::srgb(0.9, 0.9, 0.9),
},
));
});
});
This kind of code is complicated, hard to read, doesn't clearly emphasize what is important, isn't particularly re-usable, and is horribly nested. It's no wonder why so many new Bevy developers say Bevy-UI isn't usable.
It is usable, we just lack the tooling to make it simple. I am confident that Bevy will eventually have such tools, but if we're building games in Bevy today, we need a solution today.
Hooking Up sickle_ui
We can greatly improve the situation with a single dependency: sickle_ui.
Sickle provides several features that make authoring widgets in Bevy straightforward, while leveraging the built-in BevyUI layout engine:
- A library of common widgets.
- A builder interface for creating and styling widgets.
- Data-driven skins. (upcoming)
- Custom text rendering. (upcoming)
This lets you mix sickle code with raw bevy UI code without issue because sickle is built on top of bevy UI. The goal is to make working with bevy UI more ergonomic and concise while not competing with features Bevy might add in the future.
You can follow along with this tutorial using the published repo here: https://github.com/dead-money/sickle_example
Sickle is not currently published on crates.io, so you'll need to manually reference git in your cargo.toml:
[package]
name = "sickle_example"
version = "0.1.0"
edition = "2021"
[dependencies]
## Bevy
bevy = { version = "0.13" }
## Bevy Inspector Egui makes it easy to modify widgets at runtime.
bevy-inspector-egui = "0.24"
## Sickle is a UI library for Bevy.
sickle_ui = { git = "https://github.com/UmbraLuminosa/sickle_ui" }
You might even want to create and host a fork of sickle and refer to that instead. This lets you manage the rate the fork gets updated by upstream changes by migrating those changes into your own fork manually.
Notice that we've added bevy-inspector-egui to the example. This crate is really useful for tweaking your widgets at runtime. I use it a lot to find the ideal styling for my widgets before I commit them to data or code.
Elements of a Widget
Before we write any code, let's establish some goals.
- Widget code should be minimal.
- Widget code should be re-usable.
- Widget code should follow a purposeful pattern.
- Widgets should be composed of widgets.
To fulfill these goals we will:
- Use UiBuilder to reduce the Bevy UI boilerplate.
- Use extension traits to customize the UiBuilder with new features.
- Use small systems to update widgets internally.
- Use extension traits to add new EntityCommands to modify widgets externally.
It's likely the best practice will change with the next release of sickle, because it will include new ways of customizing and creating widgets. For now, this is a nice concise way to solve the problem.
Spawning a Node with UiBuilder
Bevy calls any box in the UI tree a node. When we say widget we mean something higher level. We mean a UI building block with a specific appearance and well-defined functionality. A box is a node. A label is a widget. A label utilizes nodes, functions, and components to provide a uniform way to set the text and a default styling to how it appears on screen.
The absolute simplest way to create some kind of widget with sickle is the UiBuilder:
fn spawn_simple_widget(mut commands: Commands) {
// Let's create a simple column widget on the screen.
commands.ui_builder(UiRoot).column(|column| {
// Some code would go here to customize our widget's appearance.
});
}
The UiBuilder begins with an invocation of ui_builder(context) and is followed by a chain of associated functions that create or modify nodes. In the above example, we're calling the column function to create a new flex box column. The closure we pass to column lets us style the new column or modify it by inserting components.
A column is an example of a widget. It lets us easily lay out other nodes in a column without having to manually specify the flexbox parameters that make a node act like a column. This is obviously a pretty simple widget: it doesn't have any need to update dynamically. But by building up a set of ui_builder functions, we can compose complicated widgets with very little code.
In this example, we are giving the UiRoot as the builder context. The UiRoot is a root level panel that represents that base of our tree of controls. You can think of it as the screen. You might spawn HUD panels here, for example.
Styling and Populating a Node
Now lets give our node some character by styling it. In the future, we'll be able to style a widget in a data driven way and customize its appearance with skins, but for now we'll configure the style using the UiBuilder.
The closure we pass to column takes a UiBuilder as an argument - |column| - which we can use to call more UiBuilder functions. This time, the column is the context of those operations. One of those functions is style() which lets us provide in-line styling to our new panel:
fn spawn_simple_widget(mut commands: Commands) {
// Let's create a simple column widget on the screen.
commands.ui_builder(UiRoot).column(|column| {
// We can style our widget directly in code using the style method.
column
.style()
// The column will be located 100 pixels from the right and 100 pixels from the top of the screen.
// The absolute position means we are not set relative to any parent.
.position_type(PositionType::Absolute)
.right(Val::Px(100.0))
.top(Val::Px(100.0))
// We'll bound the height of our column to the total height of our contents.
// By default, a column will be 100% of the parent's height which would be the entire length of the screen.,
.height(Val::Auto)
// Lets give it a visible background color.
.background_color(Color::rgb(0.5, 0.5, 0.5));
});
}
This column will have no height because it contains no children, so lets add some labels:
// Let's add some content to our column.
column
.label(LabelConfig::default())
.entity_commands()
// We can use the set_text method to set the text of a label.
.set_text("This is label 1.", None);
column
.label(LabelConfig::default())
.entity_commands()
.set_text("This is another label.", None);
If we run this example, we get something like this:
Our panel (the little grey box with two white labels) might not look very interesting, but we got it on screen with very little work and our code is clear and expressive.
However, our panel isn't re-usable and doesn't demonstrate a dynamic update - two of the other requirements we want our widgets to be able to have.
Making our Widget Re-Usable
Let's make our widget re-usable and also make it look nicer.
I like to keep each of my widgets in their own module, so our example project includes a module called banner_widget.rs. We'll start by creating an extension trait to the UiBuilder that creates a banner widget:
#[derive(Component)]
struct BannerWidget;
pub trait UiBannerWidgetExt<'w, 's> {
fn banner_widget<'a>(&'a mut self) -> UiBuilder<'w, 's, 'a, Entity>;
}
impl<'w, 's> UiBannerWidgetExt<'w, 's> for UiBuilder<'w, 's, '_, UiRoot> {
fn banner_widget<'a>(&'a mut self) -> UiBuilder<'w, 's, 'a, Entity> {
self.spawn((NodeBundle::default(), BannerWidget))
}
}
This is a dead simple starting point for a re-usable widget. We're just spawning an empty node - not what we want - but it shows the idea. We've now created a function that we can use to spawn banner widgets any time we want:
fn spawn_banner_widgets(mut commands: Commands) {
commands.ui_builder(UiRoot).banner_widget();
commands.ui_builder(UiRoot).banner_widget();
commands.ui_builder(UiRoot).banner_widget();
}
Since banner_widget returns a UiBuilder, we can chain more functions if we want to add more customization externally:
commands
.ui_builder(UiRoot)
.banner_widget()
.style()
.position_type(PositionType::Absolute)
.right(Val::Px(0.0))
.top(Val::Px(0.0));
But it would be a pain in the ass to position every new banner widget this way. We'll solve that problem in a bit. First, lets actually populate our widget with a custom appearance.
We want to add some nice looking art and a reasonable looking font and get the default layout looking nice:
pub struct BannerWidgetConfig {
pub label: String,
// Other options can be added here...
}
impl BannerWidgetConfig {
pub fn from(label: impl Into<String>) -> Self {
Self {
label: label.into(),
}
}
}
pub trait UiBannerWidgetExt<'w, 's> {
fn banner_widget<'a>(&'a mut self, config: BannerWidgetConfig)
-> UiBuilder<'w, 's, 'a, Entity>;
}
impl<'w, 's> UiBannerWidgetExt<'w, 's> for UiBuilder<'w, 's, '_, UiRoot> {
fn banner_widget<'a>(
&'a mut self,
config: BannerWidgetConfig,
) -> UiBuilder<'w, 's, 'a, Entity> {
self.container((ImageBundle::default(), BannerWidget), |banner| {
banner
.style()
.position_type(PositionType::Absolute)
// Center the children (the label) horizontally.
.justify_content(JustifyContent::Center)
.width(Val::Px(401.0))
.height(Val::Px(79.0))
// Add a nice looking background image to our widget.
.image("banner_title.png");
// And we'll want a customizable label on the banner.
let mut label = banner.label(LabelConfig::default());
label
.style()
// Align the label relative to the top of the banner.
.align_self(AlignSelf::Start)
// Move us a few pixels down so we look nice relative to our font.
.top(Val::Px(20.0));
// We would like to set a default text style without having to pass in the AssetServer.
label.entity_commands().set_text(config.label, None);
})
}
}
Here we've added an image to our banner and a label. Some additional styling centers the text.
Notice that I pass a struct for my widget configuration. I like to do this because I may add additional configuration features in the future and I don't want to worry about really long parameter lists or having to update every widget callsite to add those new parameters.
Extending EntityCommands
We also have the problem of our text being unstyled - its just using the default font. It would be nice if we didn't have to pass the asset_server or a text style into every widget function, but a complete TextStyle requires a Font Handle
We can solve this by using a command:
struct SetFont(String, f32, Color);
impl EntityCommand for SetFont {
fn apply(self, entity: Entity, world: &mut World) {
let asset_server = world.resource::<AssetServer>();
let font = asset_server.load(&self.0);
if let Some(mut text) = world.entity_mut(entity).get_mut::<Text>() {
for text_section in &mut text.sections {
text_section.style.font = font.clone();
text_section.style.font_size = self.1;
text_section.style.color = self.2;
}
}
}
}
An extension trait exposes this command:
pub trait BannerWidgetCommands<'a> {
fn font(
&'a mut self,
font: impl Into<String>,
size: f32,
color: Color,
) -> &mut EntityCommands<'a>;
}
impl<'a> BannerWidgetCommands<'a> for EntityCommands<'a> {
fn font(
&'a mut self,
font: impl Into<String>,
size: f32,
color: Color,
) -> &mut EntityCommands<'a> {
self.add(SetFont(font.into(), size, color))
}
}
Which we can then use inside the widget:
// extended config, if you wanted to pass in a font
pub struct BannerWidgetConfig {
pub label: String,
pub font: String,
pub font_size: f32,
}
// updated callsite:
label
.entity_commands()
.insert(BannerLabel)
.set_text(config.label, None)
.font(
config.font,
config.font_size,
Color::rgb(0.471, 0.278, 0.153),
);
Now our widget looks like this:
We can also create, position, and name more than one if we want, demonstrating re-usability:
fn spawn_banner_widgets(mut commands: Commands) {
commands
.ui_builder(UiRoot)
.banner_widget(BannerWidgetConfig::from("Hello, World!"))
.style()
.left(Val::Px(100.0))
.bottom(Val::Px(100.0));
commands
.ui_builder(UiRoot)
.banner_widget(BannerWidgetConfig::from("Bonjour, le Monde!"))
.style()
.left(Val::Px(300.0))
.bottom(Val::Px(300.0));
commands
.ui_builder(UiRoot)
.banner_widget(BannerWidgetConfig::from("¡Hola, Mundo!"))
.style()
.left(Val::Px(600.0))
.bottom(Val::Px(100.0));
}
Updating the Position
We can make positioning our widget easier by adding an extension trait to EntityCommands that sets the position directly:
// This extension trait lets us call set_position on an entity command queue for a banner widget.
// (Really, this is not constrained to just a banner widget and could be used on any widget.)
pub trait BannerWidgetCommands<'a> {
fn set_position(&'a mut self, x: f32, y: f32) -> &mut EntityCommands<'a>;
}
impl<'a> BannerWidgetCommands<'a> for EntityCommands<'a> {
fn set_position(&'a mut self, x: f32, y: f32) -> &mut EntityCommands<'a> {
// We insert our custom command into the entity commands queue.
self.add(SetPosition(x, y))
}
}
struct SetPosition(f32, f32);
impl EntityCommand for SetPosition {
fn apply(self, entity: Entity, world: &mut World) {
// Commands work with direct access to the world.
// We can set the position by modifying the style directly:
if let Some(mut style) = world.entity_mut(entity).get_mut::<Style>() {
style.position_type = PositionType::Absolute;
style.left = Val::Px(self.0);
style.top = Val::Px(self.1);
style.right = Val::Auto;
style.bottom = Val::Auto;
}
// Because you have access to the world, you could access resources or perform queries here.
}
}
And once we have this command, we can change the position of a banner on the fly if we want:
fn move_banner_example(
mut commands: Commands,
examples: Query<Entity, With<FlyingExample>>,
time: Res<Time>,
) {
for entity in examples.iter() {
commands.entity(entity).set_position(
700.0 + time.elapsed_seconds().sin() * 100.0,
100.0 + time.elapsed_seconds().cos() * 100.0,
);
}
}
Wheee!
No idea why clip champ made this such a low quality gif, but you get the idea.
Of course, this is just an example! sickle provides a SetAbsolutePosition command for us already that does the same thing.
Updating a Widget Dynamically
The last thing I want to cover in this post is how we can make our widget update dynamically. The most direct way to make a widget responsive is to use a system.
Let's say we want a widget that shows the current framerate.
We could set up a widget with a label, similar to the banner (see the github repo for this example) and then we can just run a system that updates any dynamic elements:
fn update_fps(
mut commands: Commands,
diagnostics: Res<DiagnosticsStore>,
label: Query<Entity, With<FpsText>>,
asset_server: Res<AssetServer>,
) {
for label in &label {
let Some(fps_diagnostic) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) else {
continue;
};
let Some(smoothed_fps) = fps_diagnostic.smoothed() else {
continue;
};
// Target frame rate for 60 Hz monitors is actually slightly less than 60,
// so we round down slightly to avoid flickering under happy circumstances.
let text_color = if smoothed_fps > 59.5 {
Color::GREEN
} else if smoothed_fps > 30.0 {
Color::YELLOW
} else {
Color::RED
};
let text_style = TextStyle {
font: asset_server.load("FiraSans-Bold.ttf"),
font_size: 60.0,
color: text_color,
};
commands
.entity(label)
.set_text(format!("FPS: {:3.0}", smoothed_fps), text_style.into());
}
}
This example uses a marker component called FpsText to tag the label we want to call set_text on. An alternative would be to store the Entity ID of the label somewhere and access that. Either approach works and you can pick one that conforms best to your normal system design patterns or specific needs.
It's important to note that you generally only want to update the layout of widgets that exist and have changed.
You can constrain your systems to run only if a given component exists like this:
.add_systems(
Update,
(
update_requirements_on_change.run_if(any_with_component::<RequirementsList>),
update_requirements_on_create.run_if(any_with_component::<RequirementsList>),
),
)
Here we're using run_if(any_with_component) to create a simple execution condition.
If we design our widgets to use components to track the data that underlies the widget state, we can use Bevy's change detection to only update widgets that have changed:
fn update_checkbox(q_checkboxes: Query<&Checkbox, Changed<Checkbox>>, mut commands: Commands) {
for checkbox in &q_checkboxes {
commands
.style(checkbox.check_node)
.visibility(match checkbox.checked {
true => Visibility::Inherited,
false => Visibility::Hidden,
});
}
}
Conclusion
There is quite a lot more that sickle can do. You can implement drag and drop, editor-like controls, game-hud like widgets, etc.
Here's the example that comes with the library:
And here's UI from my game, Architect of Ruin, that uses sickle:
Each element of the crafting UI is derived from a series of re-usable widgets, configurable with commands, and responsive to external state changes.
For example, each item icon on the crafting page is an instance of an item_icon():
commands
.ui_builder(container)
.item_icon(ItemIconConfig {
item_id: deposit_data.item,
show_name: false,
show_quantity: Some(deposit_yield.max as i32),
})
I have found that using sickle has greatly accelerated my own ability to quickly realize high quality UI in Bevy.
You have to invest some up-front time in learning patterns and building a library of re-usable widgets, but once you've got these tools built (which you'd likely need to utilize any UI library) you can compose complicated interfaces with relative ease!
Seldom
UserSeldom
User ·