Logo

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 (WIP)
    • Wanting to make UI quickly (WIP)

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.

Comparison with bevy_ui

While bevy_ui offers a straightforward approach to UI creation within Bevy, Bevy_Lunex provides a more advanced and hands-on alternative. Additionally, the ability to integrate both 2D and 3D elements and the seamless extension of UI behavior through ECS components make Bevy_Lunex a powerful tool for developers aiming to create sophisticated and stylized user interfaces.

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.0" }

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 works by first creating an entity that will contain the future UI. 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 child.

Boilerplate

#![allow(unused)]
fn main() {
use bevy_lunex::prelude::*;
}

Then we need to add UiPlugin to our app.

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, UiPlugin))
        .run();
}

Thats it for the boilerplate!

Start

Because the library supports a lot of different use cases, we need to specify what dimensions will the UI be rendered with.

Right now we want to use the window size, so we will use the default marker component and add it to our camera. This will make the camera pipe it's size into our future UiTree which also needs to have the same marker applied.

#![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() }
));
}

Now we need create our UiTree entity. The core components are UiTree + Dimension + Transform. The UiTreeBundle already contains these components for our ease of use.

The newly introduced Dimension component is used as the source size for the UI system. We also need to add the MovableByCamera component so our entity will receive updates from 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
    MovableByCamera,

    // This is our UI system
    UiTreeBundle::<MainUi>::from(UiTree::new("Hello UI!")),

)).with_children(|ui| {
    // Here we will spawn our UI as children
});
}

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 add a UiImage2dBundle to the entity to add images to your widgets. Or you can add another UiTree as a child, which will use the computed size output in Dimension component instead of a Camera piping the size to it.

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]
}

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.

Debug overlay

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 with 0.0 as default
  • align_y - Vertical alignment, -1.0 to 1.0 with 0.0 as default
  • scaling - If the container should fit inside parent or fill 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, usually Ab(1) = 1px
  • Rl - Stands for relative, it means Rl(1.0) == 1%
  • Rw - Stands for relative width, it means Rw(1.0) == 1%w, but when used in height field, it will use width as source
  • Rh - Stands for relative height, it means Rh(1.0) == 1%h, but when used in width field, it will use height as source
  • Em - Stands for size of symbol M, it means Em(1.0) == 1em, so size 16px if font size is 16px
  • Sp - Stands for remaining space, it's used as proportional ratio between margins, to replace alignment and justification. Only used by Div
  • Vp - Stands for viewport, it means Vp(1.0) == 1v% of the UiTree original size
  • Vw - Stands for viewport width, it means Vw(1.0) == 1v%w of the UiTree original size, but when used in height field, it will use width as source
  • Vh - Stands for viewport height, it means Vh(1.0) == 1v%h of the UiTree 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 API for a cursor within your Bevy application.

This feature works by spawning a cursor atlas image alongside a special Cursor2d component as a child of a 2D camera.

To use the custom cursor styling, we need to expand our Camera2d entity as follows:

#![allow(unused)]
fn main() {
fn setup(mut commands: Commands, assets: Res<AssetServer>, mut atlas_layout: ResMut<Assets<TextureAtlasLayout>>){
commands.spawn((
    MainUi,
    Camera2dBundle { transform: Transform::from_xyz(0.0, 0.0, 1000.0), ..default() }
)).with_children(|camera| {

    // Spawn cursor
    camera.spawn ((

        // Here we can map different native cursor icons to texture atlas indexes and sprite offsets
        Cursor2d::new().native_cursor(false)
            .register_cursor(CursorIcon::Default, 0, (14.0, 14.0))
            .register_cursor(CursorIcon::Pointer, 1, (10.0, 12.0))
            .register_cursor(CursorIcon::Grab, 2, (40.0, 40.0)),

        // Add texture atlas to the cursor
        TextureAtlas {
            layout: atlas_layout.add(TextureAtlasLayout::from_grid(UVec2::splat(80), 3, 1, None, None)),
            index: 0,
        },

        // Add sprite bundle to the cursor
        SpriteBundle {
            texture: assets.cursor.clone(),
            transform: Transform { scale: Vec3::new(0.45, 0.45, 1.0), ..default() },
            sprite: Sprite {
                color: Color::BEVYPUNK_YELLOW.with_alpha(2.0),
                anchor: Anchor::TopLeft,
                ..default()
            },
            ..default()
        },

        // Make the raycaster ignore this entity, we don't want our cursor to block clicking
        Pickable::IGNORE,
    ));
});
}
}

When creating a Cursor2d component, you can use the native_cursor() method to specify whether the cursor should exist as an entity within the game world or be injected into the Winit crate as a custom cursor sprite. (Note: This feature is currently a work in progress, and enabling it only hides the sprite for now.)

Cursor

By default, spawning the cursor entity will hide the native system cursor unless native_cursor(true) is set.

Additionally, you must register each cursor icon with its respective texture atlas indices and sprite offsets to define the appearance and positioning of different cursor states.

Finally, to prevent the cursor from interfering with clicking events, we add the Pickable::IGNORE component. This ensures that the cursor sprite does not block any button interactions or other clickable elements in the UI.

Cursor position

You can query for GlobalTransform of Cursor2d to get it's worldspace location. For localspace, use regular Transform.

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. Currently it is hardcoded, but in the future you will be able to specify which unit to use.

#![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, // Currently hardcoded as Relative height (Rh) - so 60% of the node height
            color: Color::RED,
        }),
    ..default()
},
}

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 CustomButtom {
    // 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::new("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(UiGenericPlugin::<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::new("MyRoute")),
                MovableByCamera,
            )).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.

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.

To block picking, set Pickable::IGNORE on non-UI entities.

#![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() {
fn button_click_system(mut events: EventReader<UiClickEvent>, query: Query<&CustomButton>) {
    for event in events.read() {
        if let Ok(button) = query.get(event.target) {
            info!("Pressed button: {}", button.text);
        }
    }
}
}

Note that UiClickEvent is not emitted automatically; you need to add the 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 might prove 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),
));
}

By following these steps, you can successfully merge the 2D and 3D worlds in your game.

Worldspace UI

Coming soon...

Custom rendering

Coming soon...

Contributors

This documentation is maintained by

Help

For issues related to the library, please create a ticket on GitHub.

You can also reach out on the Bevy Discord, where I am likely to see your message.

For questions or additional help, feel free to send me a direct message on Discord: @idedary.