Bevy_Lunex is a blazingly fast, path-based retained layout engine designed for creating UI within the Bevy game engine.
Centered around Bevy ECS, Bevy_Lunex allows developers to manage their UI elements with the same principles and tools used for other game entities.
-
Lunex is great for:
- Making a game-like user interface
- Enjoying hands on approach
- Requiring custom widgets
- Requiring worldspace (diegetic) UI
-
Lunex is not optimal for:
- Making a dektop application
- Wanting to make UI quickly
- Complex editor-like UI
Syntax Example
This is an example of a clickable Button created from scratch using predefined components. As you can see, ECS modularity is the focus here. The library will also greatly benefit from upcoming BSN (Bevy Scene Notation) addition that Cart is working on.
#![allow(unused)] fn main() { commands.spawn(( // #=== UI DEFINITION ===# // This specifies the name and hierarchy of the node UiLink::<MainUi>::path("Menu/Button"), // Here you can define the layout using the provided units (per state like Base, Hover, Selected, etc.) UiLayout::window().pos(Rl((50.0, 50.0))).size((Rh(45.0), Rl(60.0))).pack::<Base>(), // #=== CUSTOMIZATION ===# // Give it a background image UiImage2dBundle { texture: assets.load("images/button.png"), ..default() }, // Make the background image resizable ImageScaleMode::Sliced(TextureSlicer { border: BorderRect::square(32.0), ..default() }), // This is required to control our hover animation UiAnimator::<Hover>::new().forward_speed(5.0).backward_speed(1.0), // This will set the base color to red UiColor<Base>::new(Color::RED), // This will set hover color to yellow UiColor<Hover>::new(Color::YELLOW), // #=== INTERACTIVITY ===# // This is required for hit detection (make it clickable) PickableBundle::default(), // This will change cursor icon on mouse hover OnHoverSetCursor::new(CursorIcon::Pointer), // If we click on this, it will emmit UiClick event we can listen to UiClickEmitter::SELF, )); }
^^^ Source from Bevypunk repo
Key Features
-
Path-Based Hierarchy: Bevy_Lunex utilizes its own custom hierarchy alongside Bevy's default hierarchy. This approach circumvents the borrow checking rules enforced by Rust, which can obstruct the ability to access data fluidly. This is achieved by introducing an iterable, hashmap-like "god" struct (
UiTree
component) that contains all the UI data. Developers navigate this structure using a syntax reminiscent of Unix file systems, such as"foo/bar"
, to retrieve specific data or bind entities to it. -
Retained UI: Most UI frameworks for Bevy operate on an immediate mode basis, recalculating every tick. In contrast, Bevy_Lunex employs a retained mode, meaning that the UI state is stored and only recomputed when changes occur. This results in improved performance and reduced energy consumption, making Bevy_Lunex an efficient choice for UI development.
-
ECS Friendly: Traditional UI frameworks often impose special rules on UI entities, isolating them from the rest of the application's logic. Bevy_Lunex adopts a different approach, treating UI entities as regular game entities. By leveraging the
Transform
component, any entity, including 3D models, can be integrated into the layout engine. This design ensures seamless interaction and uniform behavior across all entities in the application. -
Resizable Layouts: As a game-first UI framework, Bevy_Lunex is designed to support all aspect ratios and resolutions out of the box. UI layouts automatically adapt to different window sizes without collapsing or requiring explicit instructions for various circumstances. For instance, a UI designed for a 1920x1080 pixel window will maintain its layout proportionally when scaled down to a 1280x720 pixel window, simply appearing smaller. This behavior is ideal for games, though it may differ from traditional HTML-based layouts. For regular applications requiring different behavior, the
Div
layout (currently a work in progress) is recommended.
Installation
Installing Bevy_Lunex
is straightforward, just like any other Rust crate.
Add the following to your Cargo.toml
:
[dependencies]
bevy_lunex = { version = "0.2.4" }
Alternatively, you can use the latest bleeding edge version from the Git repository:
[dependencies]
bevy_lunex = { git = "https://github.com/bytestring-net/bevy_lunex" }
Bevy
To avoid potential conflicts with bevy_ui
, you can disable the default features and enable them manually. This prevents mixing different UI crates and reduces confusion. Refer to Bevy's Cargo.toml file for the complete list of features.
Add the following to your Cargo.toml
:
bevy = { version = "0.14.0", default_features = false, features = [
# Core
"bevy_core_pipeline",
"multi_threaded",
"bevy_winit",
"bevy_audio",
"bevy_sprite",
"bevy_text",
# Core formats
"vorbis",
"png",
# ... Enable what you need here
] }
Quick start
Explanation
Lunex is first and foremost a worldspace layout engine, which means that ALL your UI entities exist in the same space as your game objects.
This for example means, that if you have a moving camera, you HAVE TO spawn your UI as children of the camera, otherwise your UI would stay where you spawned it. (Most likely at [0,0,0])
Boilerplate
First import Lunex library
#![allow(unused)] fn main() { use bevy_lunex::prelude::*; }
Then we add UiDefaultPlugins
to our app.
fn main() { App::new() .add_plugins((DefaultPlugins, UiDefaultPlugins)) .run(); }
Thats it for the boilerplate!
Start
Lunex works by creating an entity that will contain all UI within it. This entity is called UiTree
and has a component with the same name. Afterwards, any entity that will be part of that UI needs to be spawned as it's direct child.
Because the library supports a lot of different use cases, we MUST specify what source dimensions will the UI be be using. In most cases, we want it to take camera's viewport size.
To do that, you can tag your camera with the default MainUi
marker provided by Lunex. If the library detects a camera with this marker, ALL UiTree
s with the same tag will use this camera's size as size source.
#![allow(unused)] fn main() { commands.spawn(( // Add this marker component provided by Lunex. MainUi, // Our camera bundle with depth 1000.0 because UI starts at `0` and goes up with each layer. Camera2dBundle { transform: Transform::from_xyz(0.0, 0.0, 1000.0), ..default() } )); }
UiTree
Now we need create our UiTree
entity. Use the bundle below and attach SourceFromCamera
component so our UiTree
will receive updates from our camera. The last step is adding the default MainUi
marker as a generic.
#![allow(unused)] fn main() { commands.spawn(( // This makes the UI entity able to receive camera data SourceFromCamera, // This is our UI system UiTreeBundle::<MainUi>::from(UiTree::new2d("Hello UI!")), )).with_children(|ui| { // Here we will spawn our UI as direct children }); }
UiNodes
Now, any entity with UiLayout
+ UiLink
spawned as a child of the UiTree
will be managed as a UI entity. If it has a Transform
component, it will get aligned based on the UiLayout
calculations taking place in the parent UiTree
. If it has a Dimension
component then its size will also get updated by the UiTree
output.
This allows you to create your own systems reacting to changes in Dimension
and Transform
components.
You can very easily for example write a custom renderer system, that styles your nodes based on measurements in these components.
To quickly attach an image to our node, you can add a UiImage2dBundle
to the entity to add images to your widgets.
The generic in pack::<S>()
represents state. For now leave it at Base
, but when you for example later want to add hover animation use Hover
instead.
#![allow(unused)] fn main() { ui.spawn(( // Link the entity UiLink::<MainUi>::path("Root"), // Specify UI layout UiLayout::window_full().pos(Ab(20.0)).size(Rl(100.0) - Ab(40.0)).pack::<Base>(), )); ui.spawn(( // Link the entity UiLink::<MainUi>::path("Root/Rectangle"), // Specify UI layout UiLayout::solid().size(Ab((1920.0, 1080.0))).pack::<Base>(), // Add image to the entity UiImage2dBundle::from(assets.load("background.png")), )); }
UiLink
is what is used to define the the custom hierarchy. It uses /
as the separator. If any of the names don't internally exist inside the parent UiTree
, it will create them.
As you can see in the terminal (If you have enabled debug
feature or added the UiDebugPlugin
), the structure looks like this:
#![allow(unused)] fn main() { > MyUiSystem == Window [pos: (x: 0, y: 0) size: (x: 100%, y: 100%)] |-> Root == Window [pos: (x: 20, y: 20) size: (x: -40 + 100%, y: -40 + 100%)] | |-> Rectangle == Solid [size: (x: 1920, y: 1080) align_x: 0 align_y: 0] }
You can read more about this hierarchy in Linking
Debug
Lunex offers great debugging functionality. To enable these features, enable debug
feature or add the following plugin:
#![allow(unused)] fn main() { App::new() .add_plugins(UiDebugPlugin::<MainUi>::new()) .run(); }
This will draw gizmo outlines around all your nodes, allowing you to see their positions and sizes.
Additionally, it will print the UiTree
to the console whenever a change is detected. This can be extremely useful for debugging your UI.
#![allow(unused)] fn main() { > MainMenu == Window [pos: (x: 0, y: 0) size: (x: 100%, y: 100%) anchor: TopLeft] |-> Background == Solid [size: (x: 2968, y: 1656) align_x: 0 align_y: 0] |-> Solid == Solid [size: (x: 881, y: 1600) align_x: -0.74 align_y: 0] | |-> Board == Window [pos: (x: 50%, y: 0) size: (x: 105%, y: 105%) anchor: TopCenter] | | |-> CONTINUE == Window [pos: (x: 0, y: 0) size: (x: 100%, y: 14%) anchor: TopLeft] | | |-> NEW GAME == Window [pos: (x: 0, y: 17%) size: (x: 100%, y: 14%) anchor: TopLeft] | | |-> LOAD GAME == Window [pos: (x: 0, y: 34%) size: (x: 100%, y: 14%) anchor: TopLeft] | | |-> SETTINGS == Window [pos: (x: 0, y: 51%) size: (x: 100%, y: 14%) anchor: TopLeft] | | |-> ADDITIONAL CONTENT == Window [pos: (x: 0, y: 68%) size: (x: 100%, y: 14%) anchor: TopLeft] | | |-> CREDITS == Window [pos: (x: 0, y: 85%) size: (x: 100%, y: 14%) anchor: TopLeft] | | |-> QUIT GAME == Window [pos: (x: 0, y: 102%) size: (x: 100%, y: 14%) anchor: TopLeft] }
And this will also output detailed information to the console, which can be useful if you are integrating custom logic into the system.
#![allow(unused)] fn main() { INFO bevy_lunex::systems: -> UiTree - Fetched Transform data from Camera INFO bevy_lunex::systems: <> UiTree - Recomputed INFO bevy_lunex::systems: <- Foo/Bar - Linked ENTITY fetched Dimension data from node INFO bevy_lunex::systems: <- Foo/Bar - Linked ELEMENT fetched Transform data INFO bevy_lunex::systems: -- ELEMENT - Piped Dimension into sprite size }
Linking
Lunex is somewhat similar to bevy_rapier
, as it involves synchronizing with a separate World
. This world is none other than the UiTree
component. All UI data from its children are aggregated and synchronized into this "god-struct" that handles the layout computation.
Under the Hood
What is UiTree
under the hood, then? It is a specially modified hashmap variant called NodeTree
. Its inner structure is almost identical to how your operating system storage works. You can think of NodeTree
as an empty storage drive. You can insert so-called Nodes
, which would be directories/folders in this analogy. Into each "folder," you can insert only one "file" (which is UI layout data in this case) and an unlimited number of nested subnodes.
Let's analyze this:
#![allow(unused)] fn main() { > MyUiSystem == Window [pos: (x: 0, y: 0) size: (x: 100%, y: 100%)] |-> Root == Window [pos: (x: 0, y: 0) size: (x: 100%, y: 100%)] | |-> Rectangle == Solid [size: (x: 1920, y: 1080) align_x: 0 align_y: 0] }
These are actually NOT entities but the so-called "NodeTree file system" printed out. It is inspired by GNU/Linux's tree
command, which does exactly the same thing.
So "MyUiSystem"
is your UiTree name, and its root layout is by default set to window_full()
. Then you can see that this UiTree
has a subnode called "Root"
, which is created like this:
#![allow(unused)] fn main() { ui.spawn(( UiLink::<MainUi>::path("Root"), // Here we define the name of the node UiLayout::window_full().pack::<Base>(), // This is where we define the layout )); }
Once this entity is created and picked up by Lunex, it creates the specific "directory"
in the parent UiTree
and then sends the corresponding data like layout with it as well. Once all data are prepared, Lunex will compute the correct layouts and send back this information to these "linked entities".
Nesting
At some point, you will want to create a UI node inside another one. HTML does this too, but it is derived from syntax, like this:
<div>
<div /> // This div is inside the other div
</div>
You want this because the inner divs will affect the size of the outer div. In Lunex, you can nest by specifying a path. The /
separator is used for this. This will create a "Rectangle"
node inside of "Root"
, as shown in the top tree printout.
#![allow(unused)] fn main() { ui.spawn(( UiLink::<MainUi>::path("Root/Rectangle"), // Here we define the name of the node UiLayout::window_full().pack::<Base>(), )); }
But writing each path manually is not optimal. What if you want to change the structure? Will you change the paths of all the nodes then? Of course not.
#![allow(unused)] fn main() { let root = UiLink::<MainUi>::path("Root"); // Here we create the node link and store it ui.spawn(( root.clone(), // Here we add the link UiLayout::window_full().pack::<Base>(), // This is where we define the layout )); ui.spawn(( root.add("Background"), // We use the existing "root" link to create a chained link (same as "Root/Background") UiLayout::window_full().pack::<Base>(), // This is where we define the layout )); }
Which hierarchy to use
You will always want to use the Lunex hierarchy for all entities that should fall in the same UI system. We use Bevy's built-in hierarchy only to abstract our UI away, so we don't need to think about it.
Layouts
There are multiple layouts that you can utilize to achieve the structure you are aiming for.
Boundary
Defined by point1 and point2, it is not influenced by UI flow and is absolutely positioned.
- pos1 - Position of the top-left corner
- pos2 - Position of the bottom-right corner
This will make a node start at 20%
and end at 80%
on both axis from the parent node.
#![allow(unused)] fn main() { UiLayout::boundary() .pos1(Rl(20.0)) .pos2(Rl(80.0)) .pack::<Base>() }
Window
Defined by position and size, it is not influenced by UI flow and is absolutely positioned.
- pos - Position of the node
- anchor - The origin point relative to the rest of the node
- size - Size of the node
This will make a node centered at x: 53%
, y: 15%
and with size width: 60%
and height: 65%
.
#![allow(unused)] fn main() { UiLayout::window() .pos(Rl((53.0, 15.0))) .anchor(Anchor::Center) .size(Rl((60.0, 65.0))) .pack::<Base>() }
Solid
Defined by size only, it will scale to fit the parenting node. It is not influenced by UI flow.
- size - Aspect ratio, it doesn't matter if it is
(10, 10)
or(100, 100)
- align_x - Horizontal alignment,
-1.0 to 1.0
with0.0
as default - align_y - Vertical alignment,
-1.0 to 1.0
with0.0
as default - scaling - If the container should
fit
inside parent orfill
the parent
This layout is ideal for images, because it preserves aspect ratio under all costs.
Here we will set aspect ratio to the size of our imaginary texture (881.0, 1600.0)
in pixels.
Then we can align it horizontally.
#![allow(unused)] fn main() { UiLayout::solid() .size((881.0, 1600.0)) .align_x(-0.74) .pack::<Base>(), }
Div
Coming soon...
Units
Lunex features 9 different UI units, which are used as arguments for UiValue<T>
. The T
is expected to be f32
, Vec2
, Vec3
or Vec4
. They are used in layout functions where impl Into<UiValue<T>>
is specified as argument.
Ab
- Stands for absolute, usuallyAb(1)
= 1pxRl
- Stands for relative, it meansRl(1.0)
== 1%Rw
- Stands for relative width, it meansRw(1.0)
== 1%w, but when used in height field, it will use width as sourceRh
- Stands for relative height, it meansRh(1.0)
== 1%h, but when used in width field, it will use height as sourceEm
- Stands for size of symbol M, it meansEm(1.0)
== 1em, so size 16px if font size is 16pxSp
- Stands for remaining space, it's used as proportional ratio between margins, to replace alignment and justification. Only used byDiv
Vp
- Stands for viewport, it meansVp(1.0)
== 1v% of theUiTree
original sizeVw
- Stands for viewport width, it meansVw(1.0)
== 1v%w of theUiTree
original size, but when used in height field, it will use width as sourceVh
- Stands for viewport height, it meansVh(1.0)
== 1v%h of theUiTree
original size, but when used in width field, it will use height as source
Basic Operations
All unit types implement basic mathematical operations:
#![allow(unused)] fn main() { let a: Ab<f32> = Ab(4.0) + Ab(6.0); // -> 10px let b: Ab<f32> = Ab(4.0) * 2.0; // -> 8px }
You can also combine different unit types:
#![allow(unused)] fn main() { let a: UiValue<f32> = Ab(4.0) + Rl(6.0); // -> 4px + 6% }
If a unit is unspecified, the f32
value is considered to be in Ab
unit:
#![allow(unused)] fn main() { let a: Ab<f32> = 5.0.into(); // -> 5px }
Vector Definitions
You can easily define vectors using these units:
#![allow(unused)] fn main() { let a: UiValue<Vec2> = Ab(10.0).into(); // -> [10px, 10px] let b: UiValue<Vec2> = Ab((10.0, 15.0)).into(); // -> [10px, 15px] let c: UiValue<Vec2> = (Ab(10.0), Rl(5.0)).into(); // -> [10px, 5%] }
Works for larger vectors like Vec3
and Vec4
the same.
If you put them as arguments to impl Into<UiValue<T>>
, you don't have to call .into()
.
Cursor
Lunex provides a custom abstraction for a cursor related features within your Bevy application. This is achieved by moving all logic to a an entity that is spawned into the world. It is required that you spawn this entity, otherwise picking won't work.
Requirements
You need to spawn this bundle for native cursor to work
#![allow(unused)] fn main() { commands.spawn( CursorBundle::default() ) }
Styled cursors
You can also attach custom image to your cursor entity. First, you will also need to have all the cursor icons in a image strip like this:
Then you can use StyledCursorBundle
instead of CursorBundle
.
#![allow(unused)] fn main() { commands.spawn(StyledCursorBundle { // Put texture atlas and sprite bundle here ..default() }) }
Make sure you spawn StyledCursorBundle
as a child of 2D Camera
, otherwise the sprite would not follow the view.
Gamepad support
To bind a cursor to a gamepad, you have to add this component:
#![allow(unused)] fn main() { GamepadCursor::new(0), }
If you want the cursor to accept both Mouse and Gamepad inputs, you have to create an additional system that listens to recent input events and based on them "removes" or "adds" this component.
Currently, there is only 1 mode supported and that is Free
which means you just use your stick to move
the cursor around. There is no "jumping" yet.
However, it is planned to add Snap
mode, which makes the cursor "jump" and snap to the next node in input direction.
Example
Here's an example of how to set up a custom cursor with gamepad control:
#![allow(unused)] fn main() { // Spawn cursor camera.spawn (( StyledCursorBundle { // Here we can map different native cursor icons to texture atlas indexes and sprite offsets cursor: Cursor2d::new() .set_index(CursorIcon::Default, 0, (14.0, 14.0)) .set_index(CursorIcon::Pointer, 1, (10.0, 12.0)) .set_index(CursorIcon::Grab, 2, (40.0, 40.0)), // Add texture atlas to the cursor atlas: TextureAtlas { layout: atlas_layout.add(TextureAtlasLayout::from_grid(UVec2::splat(80), 3, 1, None, None)), index: 0, }, // Add sprite strip to the cursor sprite: SpriteBundle { texture: assets.load("cursor.png"), transform: Transform { scale: Vec3::new(0.45, 0.45, 1.0), ..default() }, sprite: Sprite { color: Color::YELLOW.with_alpha(2.0), anchor: Anchor::TopLeft, ..default() }, ..default() }, ..default() }, // Here we specify that the cursor should be controlled by gamepad 0 GamepadCursor::new(0), )); }
Text
To render text, you use UiText2dBundle
together with Window
layout.
All you have to do is specify the position and the anchor of the text node.
You can disregard any size parameters, as they get overwritten by text-size.
For text-size, the provided font_size
parameter is used, but instead of pixels it becomes Rh
unit. You can change this with UiTextSize
component.
#![allow(unused)] fn main() { // Link this widget UiLink::<MainButtonUi>::path("Text"), // Here we can define where we want to position our text within the parent node, // don't worry about size, that is picked up and overwritten automaticaly by Lunex to match text size. UiLayout::window().pos(Rl((5., 50.))).anchor(Anchor::CenterLeft).pack::<Base>(), // Add text UiText2dBundle { text: Text::from_section("Hello world!", TextStyle { font: assets.load("font.ttf"), font_size: 60.0, // By default hardcoded as Relative height (Rh) - so 60% of the node height color: Color::RED, }), ..default() }, }
You can also decouple the font size from the logical font size by adding this component. This new value will be used instead and native bevy font size will be used purely for rendering (font resolution).
#![allow(unused)] fn main() { UiTextSize::new().size(Rh(5.0)), }
Abstraction
As your user interface grows, it can become unmanageable. To address this, we will abstract our UI into higher-level components. This approach helps maintain organization and scalability.
Project Structure
I recommend setting up your project with a structure similar to the following:
src
|-- components
| |-- mod.rs
| |-- custom_button.rs
|-- routes
| |-- mod.rs
| |-- my_route.rs
|-- main.rs
We will create components
and routes
folders to contain our higher-level components. Each abstraction will have its own .rs
file.
Setting Up Components
In components/mod.rs
, we will aggregate all respective component plugins into a single plugin.
#![allow(unused)] fn main() { // components/mod.rs pub mod custom_button; pub use custom_button::*; // #=== ROUTE PLUGIN ===# use bevy::prelude::*; pub struct ComponentPlugin; impl Plugin for ComponentPlugin { fn build(&self, app: &mut App) { app // Add each component plugin .add_plugins(CustomButtonPlugin); } } }
Similarly, routes/mod.rs
will aggregate all route plugins.
#![allow(unused)] fn main() { // routes/mod.rs pub mod my_route; pub use my_route::*; // #=== ROUTE PLUGIN ===# use bevy::prelude::*; pub struct RoutePlugin; impl Plugin for RoutePlugin { fn build(&self, app: &mut App) { app // Add each route plugin .add_plugins(MyRoutePlugin); } } }
Finally, ensure these plugins are added in your main.rs
.
// main.rs mod components; mod routes; pub use bevy::prelude::*; pub use bevy_lunex::prelude::*; pub use {components::*, routes::*}; fn main() { App::new() .add_plugins((DefaultPlugins, UiPlugin)) .add_plugins(ComponentPlugin) .add_plugins(RoutePlugin) .run(); }
With this project structure in place, you can now focus on creating reusable components, making your codebase more organized and maintainable.
Components
Components (Not Bevy ECS componets, but UI components) bundle certain UI behavior under a single entity. Example can be Button, Text input, Calendar, Minimap, etc.
Creating a Component
Begin by creating a new .rs
file in the components
folder. First, define a public component that will serve as the abstraction.
#![allow(unused)] fn main() { // components/custom_button.rs /// When this component is added, a UI system is built #[derive(Component)] pub struct CustomButton { // Any fields we want to interact with should be here. text: String, } }
Best practice is that all components should be sandboxed. For that reason we need to define a new marker component, that will be used ONLY for UI inside this button component (instead of MainUi
).
#![allow(unused)] fn main() { // components/custom_button.rs /// Marker struct for the sandboxed UI #[derive(Component)] struct CustomButtonUi; }
Next, create a system that builds the component UI when the component is added. This system will insert the UiTree
component into the same entity and spawn the UI elements as children.
#![allow(unused)] fn main() { // components/custom_button.rs /// System that builds the route when the component is added fn build_route(mut commands: Commands, assets: Res<AssetServer>, query: Query<Entity, Added<CustomButtom>>) { for entity in &query { commands.entity(entity).insert(( // Insert this bundle into the entity that just got the CustomButtom component // Note that CustomButtonUi is used here instead of MainUi UiTreeBundle::<CustomButtonUi>::from(UiTree::new2d("CustomButton")), // Now spawn the UI as children )).with_children(|ui| { // Spawn some UI nodes ui.spawn(( // Link this widget // Note that CustomButtonUi is used here instead of MainUi UiLink::<CustomButtonUi>::path("Image"), // Add layout UiLayout::window_full().pack::<Base>(), // Give it a background image UiImage2dBundle { texture: assets.load("images/button.png"), sprite: Sprite { color: Color::RED, ..default() }, ..default() }, // Give the texture 9-slice tilling ImageScaleMode::Sliced(TextureSlicer { border: BorderRect::square(32.0), ..default() }), )) }); } } }
Finally, add the system to a plugin.
#![allow(unused)] fn main() { // components/custom_button.rs pub struct CustomButtonPlugin; impl Plugin for CustomButtonPlugin { fn build(&self, app: &mut App) { app // Add Lunex plugins for our sandboxed UI .add_plugins(UiGenericPlugins::<CustomButtonUi>::new()) // NOTE! Systems changing the UI need to run before UiSystems::Compute // or they will not get picked up by change detection. .add_systems(Update, build_route.before(UiSystems::Compute)); } } }
Don't forget to add this plugin in component/mod.rs
.
Spawning a component
To spawn the component, you have to spawn it as UI node of another UI system, either a route or another component.
#![allow(unused)] fn main() { // Spawning the component ui.spawn(( UiLink::<MainUi>::path("Button"), UiLayout::window().size(Rl(50.0)).pack::<Base>(), CustomButton { text: "PRESS ME!".to_string(), }, )); }
To despawn the component, call .despawn_recursive
on the spawned entity.
#![allow(unused)] fn main() { // Despawning the component commands.entity(component_entity).despawn_recursive(); }
Routes
In the context of a user interface, a "route" refers to a specific page of content, similar to HTML. Examples of routes might include the Main Menu, Settings, Inventory, etc.
Creating a Route
Begin by creating a new .rs
file in the routes
folder. First, define a public component that will serve as the abstraction for the route.
#![allow(unused)] fn main() { // routes/my_route.rs /// When this component is added, a UI system is built #[derive(Component)] pub struct MyRoute; }
Next, create a system that builds the route when the component is added. This system will insert the UiTree
component into the same entity and spawn the UI elements as children.
#![allow(unused)] fn main() { // routes/my_route.rs /// System that builds the route when the component is added fn build_route(mut commands: Commands, assets: Res<AssetServer>, query: Query<Entity, Added<MyRoute>>) { for route_entity in &query { // Make our route a spatial entity commands.entity(route_entity).insert( SpatialBundle::default() ).with_children(|route| { // Spawn some additional non UI components if you need to. // Here you can spawn the UI route.spawn(( UiTreeBundle::<MainUi>::from(UiTree::new2d("MyRoute")), SourceFromCamera, )).with_children(|ui| { // Spawn some UI nodes ui.spawn(( UiLink::<MainUi>::path("Background"), UiLayout::solid().size((1920.0, 1080.0)).scaling(Scaling::Fill).pack::<Base>(), UiImage2dBundle::from(assets.load("images/background.png")), )); }); }); } } }
Lastly, add the system to a plugin.
#![allow(unused)] fn main() { // routes/my_route.rs pub struct MyRoutePlugin; impl Plugin for MyRoutePlugin { fn build(&self, app: &mut App) { app // NOTE! Systems changing the UI need to run before UiSystems::Compute // or they will not get picked up by change detection. .add_systems(Update, build_route.before(UiSystems::Compute)); } } }
Don't forget to add this plugin in routes/mod.rs
.
Spawning a route
To spawn the route, simply call:
#![allow(unused)] fn main() { // Spawning the route commands.spawn(MyRoute); }
To despawn the route, call .despawn_recursive
on the spawned entity.
#![allow(unused)] fn main() { // Despawning the route commands.entity(route_entity).despawn_recursive(); }
With this setup, you can effectively manage different UI routes within your application, keeping your codebase organized and maintainable.
Interactivity
Lunex implements its interactivity on top of Bevy_mod_picking
. If you need more control over interactions, consider researching this crate.
This crate allows us to detect mouse clicks, hovers, drags, etc.
Requirements
-
Please note that
Cursor2d
MUST be spawned for any picking to work. -
UiLunexPickingPlugin
must be added (Part ofUiDefaultPlugins
but not part ofUiMinimalPlugins
) -
DefaultPickingPlugins.build().disable::<InputPlugin>()
version of picking plugins must also be added (Part ofUiDefaultPlugins
but not part ofUiMinimalPlugins
)
Getting started
Interactivity is achieved by utilizing Events and Systems. Lunex provides several components to simplify the process. First, ensure your entity is pickable by adding PickableBundle
for entities with sprites or meshes, and UiZoneBundle
for entities without sprite or meshes.
Pay attention to DEPTH of entities (Not just UI entities), because if they overlap your pickable node, they will block all picking events. To avoid that, you can add Pickable::IGNORE
component to any entity that might overlap your node.
#![allow(unused)] fn main() { // Make it non-obsructable for hit checking (mouse detection) Pickable::IGNORE, }
To check for mouse clicks, listen to UiClickEvent
in your systems using the following code:
#![allow(unused)] fn main() { // Here we can listen to UiClick events that hold entity ID, then retrieve that entity from our query fn button_click_system(mut events: EventReader<UiClickEvent>, query: Query<&CustomButton>) { // Iterate over all events for event in events.read() { // Get our entity if let Ok(button) = query.get(event.target) { // Process our button click info!("Pressed button: {}", button.text); } } } }
Note that UiClickEvent
is NOT emitted automatically; you need to add a component to emit this event when Pointer<Down>
is triggered, if you decide to make your own components.
#![allow(unused)] fn main() { // If we click on this node, it will emmit UiClick event on itself UiClickEmitter::SELF, // If we click on this node, it will emmit UiClick event from specified entity UiClickEmitter::new(entity), }
This component is really useful when creating complex components. You want UiClickEvent
to be emmited from the top entity in a component, so users can listen to them. This component allows you to do exactly that.
Another components that you might find useful are:
#![allow(unused)] fn main() { // If it detects UiClick event for this entity it will despawn the specified entity, great for despawning routes OnUiClickDespawn::new(entity), // If it detects UiClick event for this entity it will run the closure, great for spawning routes OnUiClickCommands::new(|commands| { commands.spawn(MyRoute); }) }
Animation
Currently, the library supports only a single type of animation from Base
-> Hover
. However, this will change in the future as the animation framework has already been laid out and just needs to be implemented for all states.
To add hover animation to a UI node, you can utilize the following component:
#![allow(unused)] fn main() { UiAnimator::<Hover>::new().forward_speed(5.0).backward_speed(1.0) }
With this component in place, you can then specify different UI properties using state generics, such as:
#![allow(unused)] fn main() { // Set base color to red UiColor::<Base>::new(Color::RED), // Set hover color to yellow UiColor::<Hover>::new(Color::YELLOW), }
You can also tween between two different layout positions by defining the hover layout like this:
#![allow(unused)] fn main() { // Hover layout specification UiLayout::window_full().x(Rl(10.0)).pack::<Hover>(), // Required to tween between states UiLayoutController::default(), }
When you need to synchronize animations on different nodes, consider using the pipe component that sends data to a specified entity:
#![allow(unused)] fn main() { // Pipe hover data to the specified entities UiAnimatorPipe::<Hover>::new(vec![text, image]), }
To receive this animation, make sure the specified entities have animator set to receiver mode:
#![allow(unused)] fn main() { UiAnimator::<Hover>::new().receiver(true), }
2D & 3D
When creating a game with both 2D and 3D elements, you may want to combine these two worlds for better visual effects or more engaging gameplay.
This can be useful for example in first-person shooters that require heads-up displays (HUDs) to display information about the player.
To achieve this fusion of 2D and 3D, you'll need to follow these steps:
1. Set up a 2D camera
First, set up your project like any other 2D setup.
#![allow(unused)] fn main() { commands.spawn( MainUi, Camera2dBundle { transform: Transform::from_xyz(0.0, 0.0, 1000.0), ..default() } ); }
2. Create a texture
Next, create a texture with the size of your viewport that will serve as the rendering target for your 3D camera.
#![allow(unused)] fn main() { // Create a texture resource that our 3D camera will render to let size = Extent3d { width: 1920, height: 1080, ..default() }; // Create the texture let mut image = Image { texture_descriptor: TextureDescriptor { label: None, size, dimension: TextureDimension::D2, format: TextureFormat::Bgra8UnormSrgb, mip_level_count: 1, sample_count: 1, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT, view_formats: &[] }, ..default() }; // Initiate the image image.resize(size); // Add our texture to asset server and get a handle let render_image = asset_server.add(image); }
3. Set up your 3D camera
Then, spawn your 3D camera anywhere, specifying the target and any additional settings you require.
#![allow(unused)] fn main() { // Spawn 3D camera commands.spawn( Camera3dBundle { camera: Camera { // To make this camera run before 2D camera order: -1, // The render target handle target: render_image.clone().into(), // For transparency clear_color: ClearColorConfig::Custom(Color::rgba(0.0, 0.0, 0.0, 0.0)), ..default() }, ..default() } ); }
4. Spawn a UI node with ImageBundle
Finally, spawn a UI node with the new image texture using the UiImage2dBundle
structure.
#![allow(unused)] fn main() { // Spawn 3D camera view ui.spawn(( root.add("Camera3d"), UiLayout::solid().size((1920.0, 1080.0)).scaling(Scaling::Fill).pack::<Base>(), UiImage2dBundle::from(render_image), PickingPortal, // You can add this component to send picking events through the viewport. )); }
By following these steps, you can successfully merge the 2D and 3D worlds in your game.
Worldspace UI
Lunex in its entirety is built in worldspace. This means every UI element is bound to a place in the game world.
You don't realize that, because when we are making a 2D game UI, we attach our UI to the Camera.
If we wish for our UI to be spawned at fixed place in the world, then just spawn it as normal entity. This can be usefull for example if you want to create in-game displays, holograms and such.
Images
For 3D images, you can take a look at this example: worldspace example. You can use the built-in abstraction which just hides the fact that all what we are really doing is creating a material with our texture and then attaching it onto a plane.
Text
3D text is currently limited and you have to use a 3rd party crate bevy_mod_billboard
. You can take a look at how integrate it here: worldspace text example
Important
When we are spawning a 3D hologram, we don't want the size to be the size of our camera, but some custom fixed size.
You can achieve that by specifying the size in the Dimension
component on UiTreeBundle
when we are spawning our tree.
If you don't do that, your UI size will default to 0 and you won't see anything if you use Rl
units.
Another fact to consider is abs_scale
. If you use UiTree::new2d
, then the abs_scale is 1. But if you use UiTree::new3d
then the abs_scale becoms 0.001
. This is to create a sensible balance between sizes when going from 2d to 3d.
Reason for that is, that in 2D Bevy, 1.0
in Transform
corresponds to 1 pixel on screen. But in 3D Bevy, 1.0
means 1 meter
. You can now see the problem. To make things easier to use, I decided that when you are working with 3D UI in Lunex, all Ab
units are in centimeters
.
Contributors
This documentation is maintained by
Help
For issues related to the library, please create a ticket on GitHub
.
You can also reach out to me on the Bevy Discord
, where you can use the #Ecosystem-crates: Bevy Lunex
thread.
For specific questions, feel free to send me a direct message on Discord: @idedary
.
Just make sure you are in the Bevy discord or otherwise I will ignore you.