Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

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(|_: On<Pointer<Click>>, mut exit: MessageWriter<AppExit>| {
    
    // Close the app on click
    exit.write(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 8 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().

Flow Layout

The flow layout is a dynamic, flexbox-like layout model. Unlike the absolute layouts (Boundary, Window, Solid), nodes with the UiLayout::flow() layout participate in the ui flow - they interact with their siblings and can react to their content.

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

commands.spawn((
    UiLayout::flow()
        .direction(UiFlowDirection::TopToBottom)
        .gap(Ab(10.0))
        .padding_all(Ab(20.0))
        .width(UiFlowSize::Grow)
        .height(Rl(50.0))
        .align(Align::CENTER)
        .justify(UiJustify::SpaceBetween)
        .pack(),
));
}

Core concepts

A flow node is described by two things:

  1. How it takes up space in its parent’s flow - the width/height sizing and margin.
  2. How its children are arranged - the direction, gap, padding, align and justify.

Sizing

Each axis of a flow node is sized with one of the [UiFlowSize] variants:

SizeBehavior
UiFlowSize::FitThe node hugs its content (text, image or children).
UiFlowSize::GrowThe node claims one Sp share of the parent’s leftover space, on top of its content.
UiFlowSize::Fixed(...)The node is sized by an explicit [UiValue].

Sizing can be further constrained with min_width/max_width/min_height/max_height clamps.

#![allow(unused)]
fn main() {
UiLayout::flow()
    .width(UiFlowSize::Grow)          // claim a share of the available width
    .max_width(Ab(600.0))            // ...but never more than 600px
    .height(UiFlowSize::Fit)          // hug the content vertically
    .pack()
}

Direction, gap, padding and margin

direction selects the layout direction of children: left-to-right, right-to-left (inverted), top-to-bottom or bottom-to-top (inverted). gap is spacing between children along that axis, padding is the spacing between the node’s bounding box and its children, and margin is the spacing around the node itself within its parent’s flow. All of them accept any [UiValue], so they can be expressed in Ab, Rl, Em, Vw, Sp… units.

The Sp unit (space)

All alignment and justification in flow layout is built on a single primitive: the Sp unit - a proportional share of the leftover space (what remains after all fixed sizes, gaps, paddings and fixed margins). Sp values are resolved by the flow engine against the leftover space, shared proportionally between all Sp claims of the children (margins and sizing):

#![allow(unused)]
fn main() {
// Two flexible children claiming 3:1 of the leftover width.
ui.spawn(UiLayout::flow().pack()).with_children(|ui| {
    ui.spawn(UiLayout::flow().width(Sp(3.0)).pack());
    ui.spawn(UiLayout::flow().width(Sp(1.0)).pack());
});

// A fixed base plus a flexible share: 50px + 1 share of the leftover.
ui.spawn(UiLayout::flow().width(Ab(50.0) + Sp(1.0)).pack());
}
  • Sp in sizing acts as a flexible claim (like flex-grow): Grow is sugar for Sp(1.0) on top of the content size.
  • Sp in margins acts as proportional spacing.
  • With no leftover space (overflow), all Sp values resolve to 0.
  • Outside of flow layout, Sp evaluates to 0.

Alignment and justification are margins

The container’s align and justify settings are not separate algorithms - they expand into default Sp margins inherited by the children. A child can override any side with its own margin; only undefined sides fall back to the template.

align (cross axis, continuous -1.0 to 1.0) splits each child’s whitespace: align: START makes children inherit margin_bottom: 1sp (they sit at the top), CENTER inherits 0.5sp on both sides, END inherits margin_top: 1sp.

justify (main axis) selects a margin template for the leftover space:

ModeInjected marginsResult
Startnonechildren packed at the start, leftover after
Centerfirst ml: 1sp, last mr: 1spthe block is centered
Endfirst ml: 1spthe block is pinned to the end
SpaceBetweenall but first ml: 1spedges pinned, equal gaps between
SpaceEvenlyall ml: 1sp, last mr: 1spequal gaps everywhere (incl. edges)
SpaceAroundall ml: 1sp + mr: 1sphalf-size edges, full gaps between

Children that claim leftover space through their sizing on an axis (Grow or Fixed with Sp) do not inherit the template on that axis - they fill the space instead, and only their own margins apply. This keeps the classic “grow to fill” behavior working with any align/justify setting.

Because everything shares one pool, a child that defines its own Sp margins joins the same distribution: with justify: SpaceBetween, a child with margin_left: Sp(2.0) gets twice the gap of its siblings.

Line wrapping

.wrapping() packs children onto multiple lines along the main axis, like flex-wrap: wrap in CSS:

#![allow(unused)]
fn main() {
ui.spawn(UiLayout::flow().width(Ab(250.0)).wrapping().gap(Ab(10.0)).pack()).with_children(|ui| {
    for _ in 0..3 {
        ui.spawn(UiLayout::flow().width(Ab(100.0)).height(Ab(50.0)).pack());
    }
});
}
  • Lines are packed greedily by the children’s footprints (size plus fixed margins); an item wider than the whole container gets a line of its own.
  • Each line resolves its own Sp pool: leftover space, justification margins and grow claims are computed per line, never across lines.
  • A line’s cross extent is its largest child footprint; align positions children within their line’s extent.
  • A Fit cross-sized wrapping container hugs the sum of its lines - the layout runs a bounded fixpoint pass so ancestors hug the wrapped size too.
  • Wrapping requires the container’s main-axis sizing to be resolvable top-down (not Fit): line packing needs to know the available extent.
  • .flipped() stacks lines from the opposite edge - the first line sits at the cross-axis end, later lines wrap toward the start.

Grid tracks

.grid(...) defines explicit tracks along the main axis, like CSS grid columns (or rows, in vertical flows). Grid is a specialized wrapping mode: items are placed sequentially into tracks, n per line, wrapping to the next line when full:

#![allow(unused)]
fn main() {
ui.spawn(UiLayout::flow()
    .width(Ab(400.0)).gap(Ab(10.0))
    .grid([UiFlowSize::Grow, UiFlowSize::Grow])
    .pack())
.with_children(|ui| {
    for _ in 0..5 { ui.spawn(UiLayout::flow().height(Ab(50.0)).pack()); }
});
}
  • Fit tracks hug their item’s footprint (auto tracks), Fixed tracks are explicit lengths, and Sp/Grow tracks claim shares of the line’s leftover space alongside the item’s Sp margins (including the ones injected by justify).
  • Items are stretched to fill their track (minus their fixed margins), floored at their minimum and capped at their maximum clamps.
  • With grid_wrap enabled (the default) full lines wrap onto the next line; disabling it keeps every item on a single line, overflowing into implicit Fit tracks.
  • Tracks run along the flow direction: use a vertical direction for row-based grids (the wrapped lines then stack along the cross axis).

Units and the parent size

Flow parameters fully support the [UiValue] unit system, with one caveat: relative units (Rl, Rw, Rh) in a node’s sizing resolve against the parent’s inner content box (minus padding and gaps along the flow axis). Because a Fit parent’s size is derived from its children, relative-sized children cannot contribute to it - they are excluded from the content-hugging computation and resolve once the parent’s size is known.

#![allow(unused)]
fn main() {
// Two children, each 50% of the parent's inner width - they exactly fill it.
ui.spawn(UiLayout::flow().gap(Ab(10.0)).pack()).with_children(|ui| {
    ui.spawn(UiLayout::flow().width(Rl(50.0)).pack());
    ui.spawn(UiLayout::flow().width(Rl(50.0)).pack());
});
}

Sp components of margins and sizing are likewise excluded from content-hugging: a Fit parent has no leftover space, so Sp cannot contribute to its hug.

Coexistence with absolute layouts

Absolute layouts (Window, Boundary, Solid) inside a flow container keep their absolute positioning - they are placed inside the flow container’s rectangle as usual and do not participate in the flow. A flow node whose parent is not a flow container (for example a Window node) is sized inside its parent according to its own Fit/Grow/Fixed sizing and placed through the same margin templates (justify along, align across its direction).

The [UiLayoutRoot] itself acts as an implicit flow container (left to right, no gap or padding) for its direct flow children.

Text flow sizing

Attach the [UiFlowText] component to a flow text node to opt into flow-aware text sizing. With wrap enabled, the width assigned by the flow engine is fed back into the text’s TextBounds, causing the text to re-wrap to its box. The layout settles within one recompute cycle as the wrapped height feeds back in.

#![allow(unused)]
fn main() {
ui.spawn((
    UiLayout::flow().width(Rl(100.0)).pack(), // any width
    UiFlowText::wrapped(),
    Text2d::new("Some wrapping paragraph"),
));
}

State machine integration

Flow parameters blend smoothly with the state machine. A node’s active flow configuration is the weighted blend of the flow layouts of all its active states (gap, padding, margin, sizing values, align), so states can animate layout parameters. Non-blendable fields (the direction, the justify mode, or mismatching sizing kinds) snap to the highest-weight state’s value.

The node’s kind (flow vs. absolute) is decided by its UiBase layout.

How it works

  1. Margin injection - each child’s undefined margin sides receive the parent’s align/justify default Sp templates.
  2. Bottom-up pass - computes content-hugging sizes and minimum sizes from the leaves up, including the children’s fixed margins.
  3. Top-down pass - resolves relative sizing, then distributes space: on overflow, children shrink water-level (largest first, floored at their minimums); on leftover space, all Sp claims (margins and sizing) share it proportionally, re-normalized when maximum clamps bind.
  4. Position pass - assigns each child’s position from padding, resolved margins and child sizes. Inverted directions mirror the placement.

Any change that can affect the flow (layout edits, hierarchy changes, text re-measurements, image loads) automatically triggers a recompute.

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 On<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>
  • Pointer<Down>
  • Pointer<Up>
  • Pointer<Drag>

These events also have metadata that you can access through the On event, 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(|_: On<Pointer<Click>>, mut exit: MessageWriter<AppExit>| {
    
    // Close the app on click
    exit.write(AppExit::Success);
});

State Machine

Every Ui-Node has an internal state machine, represented by the UiState component. States are not booleans — each state has a weight between 0.0 and 1.0, which is smoothly animated over time. The weights are then used to blend together the layouts and colors you defined for each state, which makes transitions look fluid without any extra effort.

The state with the weight is called the active state and all others are inactive.

Built-in states

  • UiBase - The default state every node starts with (always weight 1.0 when no other state is active).
  • UiHover - Enabled while the pointer is over the node, smoothly interpolates using configurable speeds.

Note

There are additional states in the works (UiSelected, UiClicked, UiIntro, UiOutro), but they are work in progress and not yet wired up.

Enabling a state

A state first needs to be enabled for the entity by adding its component. The most common one is hover, which is driven by picking observers:

  • UiHover - The state component with transition settings.
  • forward_speed - How fast the weight goes towards 1.0 when enabled.
  • backward_speed - How fast the weight falls back towards 0.0 when disabled.
  • instant - Skip the animation entirely.

Example

ui.spawn((
    // Like this you can enable a state
    UiHover::new().forward_speed(20.0).backward_speed(4.0),
    // You can define layouts per state
    UiLayout::new(vec![
        (UiBase::id(), UiLayout::window().full()),
        (UiHover::id(), UiLayout::window().x(Rl(10.0)).full())
    ]),
    // You can define colors per state
    UiColor::new(vec![
        (UiBase::id(), Color::srgba(1.0, 0.0, 0.0, 0.8)),
        (UiHover::id(), Color::srgba(1.0, 1.0, 0.0, 1.0))
    ]),
    // ... Sprite, Text, etc.

// Add observers that enable/disable the hover state component
)).observe(hover_set::<Pointer<Over>, true>)
  .observe(hover_set::<Pointer<Out>, false>);

The hover_set utility is a ready-made observer that toggles the hover state on the entity it is attached to. The generic true/false constant decides whether the state should be enabled or disabled, so you pair it with Pointer<Over> and Pointer<Out> respectively.

Tip

Hover events are automatically duplicated to all children of the observed entity, so hovering a parent node enables hover on the whole subtree. Attach the observers to the outermost node.

How the blending works

For each frame, Lunex computes the rectangle of every state’s layout and then normalizes the weights. If, for example, hover is at 0.5, the resulting node position and size are exactly halfway between the UiBase layout and the UiHover layout. The same applies to colors, which are blended in HSLA space (the hue is interpolated along the shortest arc).

If no state is active at all, the UiBase layout and color are used as fallback.

Custom states

You can define your own states by implementing UiStateTrait for a component:

  • value() - Returns the current weight, expected to be within 0.0 - 1.0. Any smoothing should happen inside this function.
#[derive(Component)]
struct UiWiggle {
    value: f32,
}

impl UiStateTrait for UiWiggle {
    fn value(&self) -> f32 {
        self.value
    }
}

You then reference the state by its id() when defining layouts and colors, the same way as built-in states. To pipe the value into the state machine, use the generic system system_state_pipe_into_manager::<UiWiggle> — it reads the component, writes the value into UiState and triggers RecomputeUiLayout for the affected nodes.

app.add_systems(Update, system_state_pipe_into_manager::<UiWiggle>);

Warning

The weight of UiBase is automatically balanced to 1.0 - (sum of all other states), so you never define it manually. If your custom state is at 1.0, the base layout is fully faded out.

Cursor Icons

Lunex provides utilities for changing the cursor icon when hovering Ui-Nodes. There are two paths: changing the native window cursor, or spawning a fully custom software cursor that you render yourself.

Both are part of the CursorPlugin, which is automatically added by UiLunexPlugins.

Native cursor

Attaching OnHoverSetCursor to a node is all you need. While the pointer hovers the node, the window cursor changes to the requested icon and reverts back once it leaves.

Example

ui.spawn((
    Name::new("Button"),
    UiLayout::window().pos(Rl((50.0, 50.0))).size((200.0, 50.0)).pack(),
    Sprite::from_image(asset_server.load("images/button.png")),
    // When hovered, it will request the cursor icon to be changed
    OnHoverSetCursor::new(SystemCursorIcon::Pointer),
));

SystemCursorIcon is re-exported from Bevy and offers all the usual variants like Pointer, Crosshair, Text, Grab and more.

Software cursor

If you want a fully custom cursor (for example an in-game themed cursor or a gamepad-driven one), spawn a SoftwareCursor entity as a child of a camera with a Sprite:

camera.spawn((
    SoftwareCursor::new(),
    Sprite::from_image(asset_server.load("images/cursor.png")),
));

While a software cursor exists, the native window cursor is hidden automatically and the software cursor takes over — it moves with the mouse, keeps the correct position even when the camera is zoomed, and emits picking events just like a real pointer.

Note

SoftwareCursor automatically attaches PointerId and Pickable::IGNORE, so the cursor entity itself never interferes with picking.

Warning

The software cursor is rendered as a Sprite, which only renders through a Camera2d. Spawn it under your 2D UI camera, or under a dedicated 2D overlay camera when your UI is 3D.

Texture atlas cursors

If your cursor sprite is a texture atlas, you can bind specific cursor icons to atlas indices using set_index. The offset (the hotspot of the icon) is subtracted from the cursor position:

camera.spawn((
    SoftwareCursor::new()
        .set_index(SystemCursorIcon::Default, 0, (0.0, 0.0))
        .set_index(SystemCursorIcon::Pointer, 1, (8.0, 0.0)),
    Sprite::from_image(atlas_image),
    TextureAtlas::from(atlas_layout),
));

OnHoverSetCursor works with software cursors out of the box — the requested icon is looked up in the atlas map and the sprite switches accordingly.

Gamepad cursor

Attaching GamepadCursor makes the software cursor controllable by a gamepad:

camera.spawn((
    SoftwareCursor::new(),
    GamepadCursor::new(),
    Sprite::from_image(asset_server.load("images/cursor.png")),
));
  • The cursor moves with the left stick and the speed scales with GamepadCursor::speed.
  • The first free gamepad is bound to the first free cursor automatically.
  • Button presses are translated into pointer presses:
    • South → primary button
    • East → secondary button
    • West → middle button

Tip

While a gamepad cursor exists, the native window cursor is kept visible so you can still see which gamepad is bound to which cursor.

2D Usage

2D UI is the most common setup — the UI lives in the regular 2D render world and scales with your camera.

Camera setup

First you need a Camera2d marked as a UI source. The UiSourceCamera::<N> component pairs the camera with Ui-Trees that fetch from the same index N:

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),
));

Important

The Vec3::Z * 1000.0 offset is not random — Ui-Node depth stacking starts at 0.0 and grows with nesting. Moving the camera back ensures deeply nested nodes are not clipped behind it.

Root setup

Then you spawn the UI root, synchronized with the camera’s viewport size via UiFetchFromCamera:

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
});

Tip

If your camera moves, spawn the root as a child of the camera instead — the UI then follows the camera around like a HUD. Keep UiFetchFromCamera so the root stays sized to the viewport.

Every child of the root becomes a Ui-Node. You attach visuals to the nodes directly — pick whatever fits your use case:

  • Sprite - The simplest option for images.
  • UiMeshPlane2d + MeshMaterial2d<ColorMaterial> - A quad mesh reconstructed from the node’s Dimension on demand, ideal for colored panels.
  • Custom mesh - Full freedom for arbitrary geometry (see Meshes for the pattern).
  • Text2d - Text rendering, see Text.
ui.spawn((
    Name::new("My Sprite"),
    // Give it some solid aspect ratio
    UiLayout::solid().size((1920.0, 1080.0)).pack(),
    // Give it a texture
    Sprite::from_image(asset_server.load("background.png")),
    // On hover change the cursor to this
    OnHoverSetCursor::new(SystemCursorIcon::Pointer),
))
.observe(|_: On<Pointer<Click>>| info!("Click!"));

Note

Lunex UI is just regular Bevy ECS — nodes are entities with Transform, so they can be picked, observed and animated like anything else in your app.

Where to go next

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(),

Flow

Defined by sizing and child arrangement, it participates in the ui flow of its parent.

  • direction - Whether children are laid out left-to-right, right-to-left, top-to-bottom or bottom-to-top
  • width/height - Fit (hug content), Grow (claim a share of the leftover space) or Fixed (explicit size, Sp components claim leftover)
  • gap - Spacing between children along the direction axis
  • padding - Spacing between the node’s bounding box and its children
  • margin - Spacing around the node within its parent’s flow (Sp claims leftover space)
  • align - Cross-axis alignment of children, expanded into default Sp margins
  • justify - Main-axis justification of children, expanded into default Sp margins
  • wrap - Pack children onto multiple lines (Fit cross sizing then hugs the lines)
  • grid - Explicit main-axis tracks (Fit/Fixed/Sp), items fill their track

Tip

This is the layout to reach for when building menus, sidebars, toolbars or any structure that should react to its content or to the window size. See the Flow Layout chapter for details.

UiLayout::flow()
    .direction(UiFlowDirection::TopToBottom)
    .gap(Ab(10.0))
    .padding_all(Ab(20.0))
    .width(Ab(300.0))
    .height(UiFlowSize::Grow)
    .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").into(),
        font_size: FontSize::Px(64.0),
        ..Default::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 the UiLayout’s Window (or Solid) 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.

3D Usage

3D UI works the same as 2D, with a few differences in the setup. There are two patterns:

  • Worldspace UI - Panels floating in your scene, like holographic screens.
  • Camera-attached HUD - UI parented to the camera, like a cockpit interface.

Both use UiLayoutRoot::new_3d(), which scales the depth stacking by 0.001 so that the typical “1 pixel per depth layer” of 2D becomes sane world units.

Important

A worldspace root is sized manually with the Dimension component in world units. A HUD root, on the other hand, needs UiFetchFromCamera paired with UiSourceCamera on the camera to keep itself synchronized with the camera’s viewport — the same mechanism as in 2D.

Worldspace UI

Spawn the root standalone and position it in the world like any other entity:

// Spawn the floating UI panel
commands.spawn((
    // Required to mark this as 3D
    UiRoot3d,
    // Use this constructor to init 3D settings
    UiLayoutRoot::new_3d(),
    // Provide the size in world units instead of camera
    Dimension::from((0.818, 0.965)),
    // The location of the UI panel
    Transform::from_translation(Vec3::new(-1.5, 1.0, 0.0)),
)).with_children(|ui| {
    // ... spawn your UI here
});

Camera-attached HUD

Spawn the root as a child of a Camera3d — it then follows the camera around, using a local transform for the offset:

commands.spawn((
    Camera3d::default(),
    // Mark the camera as the UI source for index 0
    UiSourceCamera::<0>,
    // ...
)).with_children(|camera| {

    // Spawn the HUD UI panel
    camera.spawn((
        // Required to mark this as 3D
        UiRoot3d,
        // Use this constructor to init 3D settings
        UiLayoutRoot::new_3d(),
        // Keep the UI synchronized with camera viewport size
        UiFetchFromCamera::<0>,
        // The location of the UI panel relative to the camera
        Transform::from_xyz(-0.25, 0.0, -0.8)
            .with_rotation(Quat::from_rotation_y(40.0_f32.to_radians())),
    )).with_children(|ui| {
        // ... spawn your UI here
    });
});

Note

When the camera uses an orthographic projection, the fetched viewport size is multiplied by the projection scale, converting it into world units. For panels with a fixed world size, you can instead provide a fixed Dimension::from((0.5, 0.2)) — that is what the hud example does.

Node visuals

Since everything lives in the 3D world, nodes render through meshes:

  • UiMeshPlane3d + MeshMaterial3d<StandardMaterial> - Panels, see Meshes.
  • Text3d - Text rendered via bevy_rich_text3d, see Text.

Warning

Sprite does not work in 3D — sprites only render through a Camera2d. In 3D UIs, use meshes (UiMeshPlane3d) or Text3d for node visuals instead.

Tip

The UiRoot3d marker is propagated down the whole hierarchy automatically. You can check any entity for it to tell whether it belongs to a 3D UI without walking up to the root.

Where to go next

  • Layouts - Depth stacking and 3D layout specifics.
  • Meshes - Mesh-driven node visuals.
  • Text - 3D text rendering.

Layouts 3D

The layout types are exactly the same as in 2D — Boundary, Window and Solid all work identically in 3D. What differs is how the root is sized and how depth stacking works.

Root sizing

A 3D root is sized manually through Dimension in world units (instead of being synced to a camera viewport):

commands.spawn((
    UiRoot3d,
    UiLayoutRoot::new_3d(),
    // 1.0 world unit wide, 0.5 world units tall
    Dimension::from((1.0, 0.5)),
    Transform::from_translation(Vec3::new(0.0, 1.5, 0.0)),
));

All relative units like Rl or Rh inside the tree are then resolved against this dimension, so your UI scales naturally with the panel.

Depth stacking

Ui-Nodes are stacked on the Z axis based on their nesting level — every child is placed in front of its parent. You can override this with the UiDepth component:

  • UiDepth::Add(f32) - Offset the node relative to its parent’s depth.
  • UiDepth::Set(f32) - Set the absolute depth, ignoring the parent.
ui.spawn((
    // Draw this node 5 depth layers in front of its parent
    UiDepth::Add(5.0),
    UiLayout::window().pos(Rl(50.0)).anchor(Anchor::Center).pack(),
    // ...
));

Note

In a 3D root (new_3d()), the depth value is scaled by 0.001, so 1000 depth layers equal one world unit. This keeps the stacking subtle but visible enough to fix Z-fighting between overlapping nodes.

Fixing Z-fighting

If two nodes overlap and flicker, give one of them a small depth offset:

ui.spawn((
    // Offset the background image behind the panel content
    UiDepth::Add(-0.1),
    UiLayout::solid().size((1920.0, 1080.0)).pack(),
    // ...
));

This is also useful for offsetting background images behind panels, or making overlays always render on top.

Meshes

In 3D, Ui-Node visuals are driven by meshes. Lunex ships with a plane mesh that is automatically reconstructed from the node’s Dimension whenever the layout changes.

Panel mesh

Attach UiMeshPlane3d and a MeshMaterial3d — the mesh geometry is then kept in sync with the node’s computed size for you:

ui.spawn((
    Name::new("Panel"),
    // Set the layout of this mesh
    UiLayout::window().full().pack(),
    // Provide a material to this mesh
    MeshMaterial3d(materials.add(StandardMaterial {
        base_color_texture: Some(asset_server.load("panel.png")),
        alpha_mode: AlphaMode::Blend,
        unlit: true,
        ..Default::default()
    })),
    // This component will tell Lunex to reconstruct this mesh as a plane on demand
    UiMeshPlane3d,
    // On hover change the cursor to this
    OnHoverSetCursor::new(SystemCursorIcon::Pointer),
));

Tip

unlit: true with AlphaMode::Blend is the recommended material setup for UI panels — you usually don’t want your UI affected by scene lighting.

Coloring

The UiColor component (including its state machine blending) is written into the material’s base_color automatically, so plain colored panels need no texture at all:

ui.spawn((
    UiLayout::window().full().pack(),
    MeshMaterial3d(materials.add(Color::srgb(0.2, 0.5, 0.8))),
    UiMeshPlane3d,
));

Custom meshes

You are not limited to planes. Define your own component that requires a Mesh3d and rebuild the geometry whenever the node’s Dimension changes:

#[derive(Component, Default)]
#[require(Mesh3d)]
struct CustomUiNodeShape {
    top_left: Vec2,
    top_right: Vec2,
    bottom_left: Vec2,
    bottom_right: Vec2,
}

// Rebuild the mesh whenever a node resizes
fn system_construct_custom_shape(
    mut query: Query<(&Dimension, &CustomUiNodeShape, &mut Mesh3d), Changed<Dimension>>,
    mut meshes: ResMut<Assets<Mesh>>,
) {
    for (dimension, shape, mut mesh) in &mut query {
        // ... build your mesh from `**dimension` and the corner offsets
        // mesh.0 = meshes.add(new_mesh);
    }
}

app.add_systems(PostUpdate, system_construct_custom_shape.in_set(UiSystems::PostCompute));

Note

Schedule the rebuild system in UiSystems::PostCompute — that is the set where Lunex has already finished writing the Dimension of every node.

Important

By default, Lunex picking uses rectangles derived from the layout. If your custom mesh is smaller than its bounding rectangle (or you need precise hit testing), attach NoLunexPicking to the node and add Bevy’s MeshPickingPlugin to switch to mesh raycasting for that node.

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. 2D 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 into a fontdb through a resource and then you can reference it by using the font name.

.insert_resource(LoadFonts {
    font_directories: vec!["assets/fonts".to_owned()],
    ..Default::default()
})

Embedding

Sometimes your UI is not the whole screen — you want to display a rendered camera feed inside a node, compose multiple UIs into one output, or run Lunex alongside other rendering systems.

This chapter covers the compositional patterns Lunex supports:

  • Camera - Render a camera’s output into a UI node. This is the bread and butter of embedding — for example a minimap, a picture-in-picture viewport or a 3D scene preview displayed inside a fixed aspect ratio panel in your UI.

  • Bevy UI - How Lunex coexists with Bevy’s own bevy_ui framework in a single app.

The core idea is always the same: Lunex UI is rendered by cameras into render targets like anything else in Bevy, and a UI node can display any image — including one that another camera renders into.

Where to go next

  • Camera - The dual-camera pipeline.
  • Bevy UI - Coexistence with Bevy’s UI framework.

Camera

Displaying a camera’s output inside a UI node is done through a render target: you render a scene into an image, then use that image as the texture of a UI node. Lunex helps with the sizing — the UiEmbedding component makes the rendered texture resize with the node’s Dimension.

The pipeline looks like this:

  1. Create an empty render texture (the “canvas”).
  2. Spawn a scene camera that renders your scene into the canvas.
  3. Spawn a UI node that displays the canvas as a sprite.

The canvas

// Create the canvas texture
let canvas = images.add(Image::clear_render_texture());

Image::clear_render_texture() creates a transparent image with the correct texture usages for rendering into it.

The scene camera

A regular Camera3d that renders into the canvas instead of the window. The order: -1 makes it render before the UI composition camera, so the canvas is always up to date:

commands.spawn((
    Camera3d::default(),
    // Render into the canvas instead of the window
    RenderTarget::Image(canvas.clone().into()),
    Camera {
        clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 0.5)),
        order: -1,
        ..Default::default()
    },
    Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y),
));

The UI node

Spawn the canvas as a node with a Sprite, marked with UiEmbedding so the texture resizes with the node:

// Spawn the composition camera
commands.spawn((
    Camera2d,
    // Configure it as UI source
    UiSourceCamera::<0>,
    // Set the camera location to capture spawned sprites
    Transform::from_translation(Vec3::Z * 1000.0),
    // Set the render layers to only see the canvas
    RenderLayers::from_layers(&[1]),
));

// Compose the secondary canvas camera infront of composition camera
commands.spawn((
    UiLayoutRoot::new_2d(),
    UiFetchFromCamera::<0>,
)).with_children(|ui| {

    // Plane with 3D camera canvas, 16:9 aspect ratio
    ui.spawn((
        UiLayout::solid().size((16.0, 9.0)).scaling(Scaling::Fit).pack(),
        Sprite::from_image(canvas.clone()),
        UiEmbedding,
        RenderLayers::from_layers(&[1]),
    ));
});

Note

Both the UI and its source camera live on render layer 1 here — this keeps the composition camera from seeing anything else in your scene. The Solid layout with Scaling::Fit keeps the canvas at a fixed 16:9 aspect ratio no matter the window size.

Pixelated canvas

For a retro pixelated look, render the scene into a low-resolution canvas and upscale it with nearest-neighbor sampling:

// Use nearest-neighbor sampling so upscaled pixels stay sharp
app.add_plugins(DefaultPlugins.build().set(ImagePlugin::default_nearest()));

Then create the canvas with a fixed size instead of using Image::clear_render_texture():

fn virtual_texture(width: u32, height: u32) -> Image {
    use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat, TextureUsages};
    use bevy::asset::RenderAssetUsages;

    let mut image = Image::new_fill(
        Extent3d {
            width,
            height,
            ..Default::default()
        },
        TextureDimension::D2,
        &[0, 0, 0, 0],
        TextureFormat::Bgra8UnormSrgb,
        RenderAssetUsages::default(),
    );
    image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT;
    image
}

Tip

Disable anti-aliasing on the scene camera with Msaa::Off for that raw pixelated look. The full working example lives in examples/pixelated_dualcamera in the repository.

Bevy UI

Lunex does not depend on or integrate with Bevy’s own bevy_ui framework — there are no shared components, no converters and no bridges between the two systems. This chapter explains how the two coexist in a single app and how to combine their output.

Two independent systems

  • Lunex UI lives in the regular render world — nodes are entities with Transform that render through cameras as sprites, 2D meshes or 3D meshes.
  • bevy_ui is its own layout and rendering stack, drawing through its UI camera.

Both can run in the same application without any conflicts, since they don’t share state. This makes Lunex a good fit for worldspace 3D UI while bevy_ui handles conventional screen-space widgets — or the other way around.

Layering with camera order

If both systems render to the same window, the camera order decides which one is drawn on top:

// The 3D world with your Lunex worldspace UI
commands.spawn((
    Camera3d::default(),
    Camera { order: 0, ..Default::default() },
));

// The bevy_ui camera, drawn on top of the world
commands.spawn((
    Camera2d,
    Camera { order: 1, ..Default::default() },
    IsDefaultUiCamera,
));

Use RenderLayers on the cameras and their contents if you need finer control over what each camera sees.

Compositing through render targets

For tighter integration — for example displaying a Lunex UI inside a bevy_ui widget, or the other way around — use the render target pattern from the Camera chapter:

  1. Render the Lunex UI with a dedicated camera into an image.
  2. Use that image as a texture in the other system (UiImage in bevy_ui, or a Lunex Sprite node with UiEmbedding).

Since both frameworks ultimately render to images and cameras, anything you can do with a Bevy camera composition works with either UI.

Note

If you only need one UI framework, picking just one keeps things simple — Lunex positioning is ECS-driven, so mixing is only worth it when you genuinely need both (e.g. a bevy_ui settings menu over a Lunex worldspace HUD).

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.