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
    • 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 UiTrees 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.

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 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:

Cursor

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 of UiDefaultPlugins but not part of UiMinimalPlugins)

  • DefaultPickingPlugins.build().disable::<InputPlugin>() version of picking plugins must also be added (Part of UiDefaultPlugins but not part of UiMinimalPlugins)

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.

worldspace

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.