Logo

Blazingly fast retained layout engine for Bevy entities, built around vanilla Bevy ECS. It gives you the ability to make your own custom UI using regular ECS like every other part of your app.

important

This book is made for version 0.3.X of Bevy_Lunex

note

This crate is being maintained by a university student. Don't expect updates during the semester.

warning

This crate is opinionated and thus you must decide if it is a good fit for what you want to achieve.

This is mainly because Lunex provides you with only capability to position entities, leaving everything else in your hands. The current version also lacks any kind of flexbox-like layout.

Good fit πŸ‘

  • Worldspace 3D UI
  • Spritebased 2D UI
  • Custom rendering hook
  • Very customizable
  • Low-level interactivity

Not so good πŸ‘Ž

  • Development speed & iteration
  • Using prebuilt input components
  • Making desktop application UI

Installation & Setup

Adding Bevy_Lunex to your project is straightforward, just like any other Rust crate.

Add the following to your Cargo.toml:

[dependencies]
  bevy_lunex = { version = "*" }

Alternatively, you can use the latest bleeding edge version from the Git repository:

[dependencies]
  bevy_lunex = { git = "https://github.com/bytestring-net/bevy_lunex" }

Project Setup

You have to add the UiLunexPlugins to your application.

fn main() -> AppExit {
    App::new()
        // Add necessary plugins
        .add_plugins((DefaultPlugins, UiLunexPlugins))
        .run()
}

Next you have to spawn your camera. Your main camera must have the UiSourceCamera::<N> component, with N being a constant from 0..3 range.

note

The purpose of this is that if you are creating a splitscreen game, you can have up to 4 cameras. This component tells the UI which camera's viewport size to use as the root node size.

tip

If you need more indexes, you can add UiLunexIndexPlugin::<N> for said index manually.

fn spawn_camera(mut commands: Commands) {
    // Spawn the camera
    commands.spawn((

        // This camera will become the source for all UI paired to index 0.
        Camera2d, UiSourceCamera::<0>,
        
        // Ui nodes start at 0 and move + on the Z axis with each depth layer.
        // This will ensure you will see up to 1000 nested children.
        Transform::from_translation(Vec3::Z * 1000.0),
        
        // Explained in # Chapters/Debug-Tooling section of the book
        RenderLayers::from_layers(&[0, 1]),
    ));
}

Debug Tooling

Sometimes it is hard to know why your UI is behaving unexpectedly. To help you with debugging, Lunex offers additional tooling that should make your life a little bit easier.

To enable it, you have to add UiLunexDebugPlugin::<R_2D, R_3D> to your application. The generics are constants used for RenderLayers inside the debug plugin.

  • R_2D: Specifies which render layer should 2D gizmos use.
  • R_3D: Specifies which render layer should 3D gizmos use.

If you don't use RenderLayers for any other purpose, then you can add the plugin with these values:

UiLunexDebugPlugin::<1, 2>

This also means that you have to add a properly configured RenderLayers component to your cameras if you want to see these outlines.

  • For Camera2d:

    RenderLayers::from_layers(&[0, 1])
  • For Camera3d:

    RenderLayers::from_layers(&[0, 2])

This will draw gizmo outlines around all UI nodes, allowing you to see their positions and sizes.

Debug

Additionally, it will print the layouts to the terminal whenever a change is detected.

β–Ά 11v1 β‡’ [w: 1920, h: 1080]
  β”œβ”€ Background β‡’ [w: 1920, h: 1080, d: 1] ➜ Solid
  └─ 13v1 β‡’ [w: 595, h: 1080, d: 1] ➜ Solid
  ┆  β”œβ”€ Panel β‡’ [w: 624, h: 1134, d: 2] ➜ Window
  ┆  β”œβ”€ 15v1 β‡’ [w: 624, h: 216, d: 2] ➜ Window
  ┆  β”‚  └─ Logo β‡’ [w: 624, h: 192, d: 3] ➜ Solid
  ┆  └─ 17v1 β‡’ [w: 327, h: 367, d: 2] ➜ Window
  ┆  ┆  β”œβ”€ New Game β‡’ [w: 327, h: 51, d: 3] ➜ Window
  ┆  ┆  β”‚  └─ 23v1 β‡’ [w: 327, h: 51, d: 4] ➜ Window
  ┆  ┆  β”‚  ┆  β”œβ”€ 24v1 β‡’ [w: 113, h: 31, d: 5] ➜ Window
  ┆  ┆  β”‚  ┆  └─ 25v1 β‡’ [w: 22, h: 31, d: 5] ➜ Window
  ┆  ┆  β”œβ”€ Settings β‡’ [w: 327, h: 51, d: 3] ➜ Window
  ┆  ┆  β”‚  └─ 31v1 β‡’ [w: 327, h: 51, d: 4] ➜ Window
  ┆  ┆  β”‚  ┆  β”œβ”€ 32v1 β‡’ [w: 98, h: 31, d: 5] ➜ Window
  ┆  ┆  β”‚  ┆  └─ 33v1 β‡’ [w: 22, h: 31, d: 5] ➜ Window
  ┆  ┆  └─ Quit Game β‡’ [w: 327, h: 51, d: 3] ➜ Window
  ┆  ┆  ┆  └─ 43v1 β‡’ [w: 327, h: 51, d: 4] ➜ Window
  ┆  ┆  ┆  ┆  β”œβ”€ 44v1 β‡’ [w: 111, h: 31, d: 5] ➜ Window
  ┆  ┆  ┆  ┆  └─ 45v1 β‡’ [w: 22, h: 31, d: 5] ➜ Window

Quick start

Now that we have everything setup, let's create some quick UI.

First, spawn a UiLayoutRoot. This is where our UI will start. You can specify the size of the UI viewport with Dimension component, but for 2D we don't want that. Instead we add UiFetchFromCamera::<N> with N being the index of our camera's UiSourceCamera::<N> component.

This will ensure that the Camera -> Dimension -> UiLayout pipeline will always be up to date.

// Create UI
commands.spawn((
    // Initialize the UI root for 2D
    UiLayoutRoot::new_2d(),

    // Make the UI synchronized with camera viewport size
    UiFetchFromCamera::<0>,

)).with_children(|ui| {

    // ... Here we will spawn our UI

});

And now inside the with_children closure we will spawn a red rectange node. This rectangle will be position exactly in the middle of our screen and with width 200px and height 50px.

ui.spawn((
    // You can name the entity
    Name::new("My Rectangle"),

    // Specify the position and size of the button
    UiLayout::window()
        .anchor(Anchor::Center) // Put the origin at the center
        .pos(Rl((50.0, 50.0)))  // Set the position to 50%
        .size((200.0, 50.0))    // Set the size to [200.0, 50.0]
        .pack(),

    // Color the sprite with red color
    UiColor::from(Color::srgb(1.0, 0.0, 0.0)),

    // Attach sprite to the node
    Sprite::from_image(asset_server.load("images/button.png")),

    // When hovered, it will request the cursor icon to be changed
    OnHoverSetCursor::new(SystemCursorIcon::Pointer),

// Interactivity is done through observers, you can query anything here
)).observe(|_: Trigger<Pointer<Click>>, mut exit: EventWriter<AppExit>| {
    
    // Close the app on click
    exit.send(AppExit::Success);
});

And thats it! You can of course do much more with the crate. Continue reading to learn on how to spawn text nodes, enable animations and much more!

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

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:

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:

let a: Ab<f32> = 5.0.into(); // -> 5px

Vector Definitions

You can easily define vectors using these units:

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.

tip

If you put them as arguments to impl Into<UiValue<T>>, you don't have to call .into().

Interactivity

Interactivity is done through observers. Let's recap on what observers are:

Observers are a type of a one-shot system, that is run when specific event is triggered on specific entity.

We define these observers, which take Trigger<E: Event> that specify for which event it listens. Then we attach it to a spawned entity (local observer).

We can listen to ANY event we want, even our own custom events. But in practise, the Pointer<T> events are the most common. These events are related to bevy_picking, which are fired when for example a mouse cursor clicks when pointing at the entity.

  • Pointer<Click>
  • Pointer<Over>
  • Pointer<Out>

These events also have metadata that you can access through the Trigger component, like for example which mouse button was pressed.

Example

ui.spawn((
    Name::new("Exit Button"),
    UiLayout::window()
        .anchor(Anchor::Center)
        .pos(Rl((50.0, 50.0)))
        .size((200.0, 50.0))
        .pack(),
    Sprite::from_image(asset_server.load("images/button.png")),

// Interactivity is done through observers, you can query anything here
)).observe(|_: Trigger<Pointer<Click>>, mut exit: EventWriter<AppExit>| {
    
    // Close the app on click
    exit.send(AppExit::Success);
});

Layouts 2D

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.

UiLayout::boundary()
    .pos1(Rl(20.0))
    .pos2(Rl(80.0))
    .pack()

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%.

UiLayout::window()
    .pos(Rl((53.0, 15.0)))
    .anchor(Anchor::Center)
    .size(Rl((60.0, 65.0)))
    .pack()

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

tip

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.

UiLayout::solid()
    .size((881.0, 1600.0))
    .align_x(-0.74)
    .pack(),

Text 2D

Text rendering is done by using the Bevy's built-in Text2d component in conjunction with the Window ui layout.

  • UiLayout - Specifies position and anchor only, size is ignored.
  • UiTextSize - Specifies the height of the text in proportion to parent node.
  • Text2d - Everything else works the same as normal Bevy 2D text rendering.

Example

ui.spawn((
    // Position the text using the window layout's position and anchor
    UiLayout::window().pos((Rh(40.0), Rl(50.0))).anchor(Anchor::CenterLeft).pack(),
    // This controls the height of the text, so 60% of the parent's node height
    UiTextSize::from(Rh(60.0)),
    // You can attach text like this
    Text2d::new("Button"),
    // Font size now works as "text resolution"
    TextFont {
        font: asset_server.load("fonts/Rajdhani.ttf"),
        font_size: 64.0,
        ..default()
    },
));

warning

Text2d component can ONLY be rendered with Camera2d. 3D text is a separate matter.

How does it work?

When you spawn a Text2d, Lunex will wait until Bevy computes the text bounds (glyph size, font size, etc.). After Bevy is done with the text, Lunex will take these values and put them inside UiLayout::boundary::size property scaled together with UiTextSize. After the Ui layout is computed for the given frame, it will scale the Transform so that the text fits into the node bounds.

Text 3D

Text rendering in 3D is done through bevy_rich_text3d crate which Bevy_Lunex re-exports. This is also gated behind a text3d feature if you have disabled default features.

Similar to 2D text, we use Text3d component in conjunction with the Window ui layout.

  • UiLayout - Specifies position and anchor only, size is ignored.
  • UiTextSize - Specifies the height of the text in proportion to parent node.
  • Text3d - Specifies the actual text.

important

Text3d requires some necessary setup. You have to add these 2 components with some "default" values for it to work.

  • MeshMaterial3d - Required material to work. Recommended:
    StandardMaterial {
        base_color_texture: Some(TextAtlas::DEFAULT_IMAGE),
        alpha_mode: AlphaMode::Blend,
        unlit: true,
        ..Default::default()
    }
  • Mesh3d - Required empty default Mesh3d::default() component to work.

Example

ui.spawn((
    Name::new("Panel"),
    // Set the layout of this mesh
    UiLayout::window().pos(Rl(50.0)).anchor(Anchor::Center).pack(),
    // This controls the height of the text, so 10% of the parent's node height
    UiTextSize::from(Rh(10.0)),
    // Set the text value
    Text3d::new("Hello 3D UI!"),
    // Style the 3D text
    Text3dStyling {
        size: 64.0,
        color: Srgba::new(1., 1., 1., 1.),
        align: TextAlign::Center,
        font: Arc::from("Rajdhani"),
        weight: Weight::BOLD,
        ..Default::default()
    },
    // Provide a material to this mesh
    MeshMaterial3d(materials.add(
        StandardMaterial {
            base_color_texture: Some(TextAtlas::DEFAULT_IMAGE),
            alpha_mode: AlphaMode::Blend,
            unlit: true,
            ..Default::default()
        }
    )),
    // Requires an empty mesh
    Mesh3d::default(),
));

warning

Text3d component can ONLY be rendered with Camera3d. 3D text is a separate matter.

note

bevy_rich_text3d works a bit differently than bevy_text. When styling a text, you don't provide a font handle. Instead, the font must be loaded inside a Text3dPlugin when creating the plugin.

UiLunexPlugins.set(Text3dPlugin {
    // If we use custom fonts we need to load them here.
    load_font_directories: vec!["assets/fonts".to_owned()],
    // Load system fonts.
    load_system_fonts: true,
    ..default()
})

Contributors

This documentation is maintained by

Help

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

If you need help, you can reach out to me on the Bevy Discord, where you can use the Bevy Lunex thread.

note

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.