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.6ofBevy_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, usuallyAb(1)= 1pxRl- Stands for relative, it meansRl(1.0)== 1%Rw- Stands for relative width, it meansRw(1.0)== 1%w, but when used in height field, it will use width as sourceRh- Stands for relative height, it meansRh(1.0)== 1%h, but when used in width field, it will use height as sourceEm- Stands for size of symbol M, it meansEm(1.0)== 1em, so size 16px if font size is 16pxVp- Stands for viewport, it meansVp(1.0)== 1v% of theUiTreeoriginal sizeVw- Stands for viewport width, it meansVw(1.0)== 1v%w of theUiTreeoriginal size, but when used in height field, it will use width as sourceVh- Stands for viewport height, it meansVh(1.0)== 1v%h of theUiTreeoriginal 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:
- How it takes up space in its parent’s flow - the
width/heightsizing andmargin. - How its children are arranged - the
direction,gap,padding,alignandjustify.
Sizing
Each axis of a flow node is sized with one of the [UiFlowSize] variants:
| Size | Behavior |
|---|---|
UiFlowSize::Fit | The node hugs its content (text, image or children). |
UiFlowSize::Grow | The 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());
}
Spin sizing acts as a flexible claim (like flex-grow):Growis sugar forSp(1.0)on top of the content size.Spin margins acts as proportional spacing.- With no leftover space (overflow), all
Spvalues resolve to0. - Outside of flow layout,
Spevaluates to0.
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:
| Mode | Injected margins | Result |
|---|---|---|
Start | none | children packed at the start, leftover after |
Center | first ml: 1sp, last mr: 1sp | the block is centered |
End | first ml: 1sp | the block is pinned to the end |
SpaceBetween | all but first ml: 1sp | edges pinned, equal gaps between |
SpaceEvenly | all ml: 1sp, last mr: 1sp | equal gaps everywhere (incl. edges) |
SpaceAround | all ml: 1sp + mr: 1sp | half-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
Sppool: leftover space, justification margins and grow claims are computed per line, never across lines. - A line’s cross extent is its largest child footprint;
alignpositions children within their line’s extent. - A
Fitcross-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()); }
});
}
Fittracks hug their item’s footprint (auto tracks),Fixedtracks are explicit lengths, andSp/Growtracks claim shares of the line’s leftover space alongside the item’sSpmargins (including the ones injected byjustify).- Items are stretched to fill their track (minus their fixed margins), floored at their minimum and capped at their maximum clamps.
- With
grid_wrapenabled (the default) full lines wrap onto the next line; disabling it keeps every item on a single line, overflowing into implicitFittracks. - 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
- Margin injection - each child’s undefined margin sides receive the
parent’s
align/justifydefaultSptemplates. - Bottom-up pass - computes content-hugging sizes and minimum sizes from the leaves up, including the children’s fixed margins.
- 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
Spclaims (margins and sizing) share it proportionally, re-normalized when maximum clamps bind. - 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 weight1.0when 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 towards1.0when enabled.backward_speed- How fast the weight falls back towards0.0when 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 within0.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
UiBaseis automatically balanced to1.0 - (sum of all other states), so you never define it manually. If your custom state is at1.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
SoftwareCursorautomatically attachesPointerIdandPickable::IGNORE, so the cursor entity itself never interferes with picking.
Warning
The software cursor is rendered as a
Sprite, which only renders through aCamera2d. 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 buttonEast→ secondary buttonWest→ 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.0offset is not random — Ui-Node depth stacking starts at0.0and 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
UiFetchFromCameraso 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’sDimensionon 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 - The three available layout types.
- Text - 2D text rendering.
- Interactivity - Observers and pointer events.
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.0with0.0as default - align_y - Vertical alignment,
-1.0 to 1.0with0.0as default - scaling - If the container should
fitinside parent orfillthe 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) orFixed(explicit size,Spcomponents 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 (
Spclaims leftover space) - align - Cross-axis alignment of children, expanded into default
Spmargins - justify - Main-axis justification of children, expanded into default
Spmargins - wrap - Pack children onto multiple lines (
Fitcross 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
Text2dcomponent can ONLY be rendered withCamera2d. 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
Dimensioncomponent in world units. A HUD root, on the other hand, needsUiFetchFromCamerapaired withUiSourceCameraon 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 thehudexample does.
Node visuals
Since everything lives in the 3D world, nodes render through meshes:
UiMeshPlane3d+MeshMaterial3d<StandardMaterial>- Panels, see Meshes.Text3d- Text rendered viabevy_rich_text3d, see Text.
Warning
Spritedoes not work in 3D — sprites only render through aCamera2d. In 3D UIs, use meshes (UiMeshPlane3d) orText3dfor node visuals instead.
Tip
The
UiRoot3dmarker 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 by0.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: truewithAlphaMode::Blendis 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 theDimensionof 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
NoLunexPickingto the node and add Bevy’sMeshPickingPluginto 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
Text3drequires 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 defaultMesh3d::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
Text3dcomponent can ONLY be rendered withCamera3d. 2D text is a separate matter.
Note
bevy_rich_text3dworks a bit differently thanbevy_text. When styling a text, you don’t provide a font handle. Instead, the font must be loaded into afontdbthrough 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_uiframework 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
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:
- Create an empty render texture (the “canvas”).
- Spawn a scene camera that renders your scene into the canvas.
- 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
1here — this keeps the composition camera from seeing anything else in your scene. TheSolidlayout withScaling::Fitkeeps 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::Offfor that raw pixelated look. The full working example lives inexamples/pixelated_dualcamerain 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
Transformthat render through cameras as sprites, 2D meshes or 3D meshes. bevy_uiis 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:
- Render the Lunex UI with a dedicated camera into an image.
- Use that image as a texture in the other system (
UiImageinbevy_ui, or a LunexSpritenode withUiEmbedding).
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_uisettings 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.