Table of Contents
- UI System
- What's live today
- What's in piece 1 — widget tree + layout
- Building a widget tree
- Sizing
- Padding, margin, alignment
- Layout modes
- DPI
- Widget ids and lookups
- RON dual-edit
- What's in piece 2 — styling & theming
- What's in piece 3 — text shaping & glyph atlas
- The atlas is the cache
- Quick example
- Limitations (deliberate, scoped to piece 3)
- Font choice
- Why ab_glyph
- What's in piece 4a — screen-space overlay render pass
- What's in piece 4b — world-space UI panels
- What's in piece 5 — input routing
- What's in piece 6 — events + data binding
- Events: immediate-mode queries on RouterFrame
- Data binding: Widget::value: Option<WidgetValue>
- Why immediate-mode
- What's deliberately not in piece 6
- Tests
- What's in piece 8 — examples/ui_hud
- What's coming in the rest of Stage 8
UI System
The oxide_engine::ui module is the engine's in-game UI system — what an
exported game uses to draw menus, HUDs, and tools. It is intentionally
separate from the editor's egui (which stays editor-only): a shipped game
cannot link egui, so the runtime owns its own widget tree, lays it out,
batches it through the Stage-5 render pipeline, and routes input through the
Stage-7 model.
Stage 8 ships in pieces. This document covers what is live today and tells you where the rest is going.
What's live today
- Piece 1 — widget tree + layout (data structures, three layout modes, pure-logic layout function). See below.
- Piece 2 — styling & theming (per-widget visual overrides, named-style themes, RON cascade). See below.
- Piece 3 — text shaping & glyph atlas (TTF loading via
ab_glyph, shelf-packed R8 atlas, multi-font line wrapping with alignment + DPI scaling). See below. - Piece 4a — screen-space overlay render pass (
paintturns a laid-out tree into draw commands;UiOverlayPassbatches them through wgpu with one R8 atlas and one alpha-blended pipeline). See below. - Piece 4b — world-space UI panels (
UiPanelcarries aWidgettree + pixel/world sizes;UiBatch::world_space(...)composes the MVP that places the UI on a 3D quad through a perspective camera). See below. - Piece 5 — input routing (
Routerwalks theLayoutTreeagainst the Stage-7InputState, tracks hover / press / focus per widget, and emits events plus capture flags the host uses to decide whether the game also receives the input). See below. - Piece 6 — events + data binding (immediate-mode queries on
RouterFrame—clicked_left("play")etc. — plus typedWidgetValues on the tree so game state and widget state round-trip each frame). See below. - Piece 7 —
examples/ui_menu(runnable main menu + settings panel built entirely from the Stage-8 stack: themed buttons, a draggable volume slider, a clickable invert-Y checkbox, Back/Quit navigation). Run withcargo run -p oxide-examples --bin ui_menu. - Piece 8 —
examples/ui_hud(a game HUD composited on top of a live 3D scene: the Stage-4ForwardPassrenders the spinning cube/sphere/ plane, then a screen-spaceUiOverlayPassdraws corner-anchored HP/Ammo chips, a minimap stand-in with an orbiting dot, and a centre crosshair — with animated digits that demonstrate the glyph-atlas cache reaching steady state). Run withcargo run -p oxide-examples --bin ui_hud. See below. - Editor UI canvas (Stage 8.5 piece 7) — the editor's UI Canvas panel
authors a
UiPaneldocument visually: a widget-tree view (positionalWidgetPathaddressing), an Add palette (Leaf/Row/Column/Grid/Anchor), a scaled live preview, and a property panel (id, text, colors, font size, font-asset picker, layout sizing). Edits are undoable and the document saves as aui/asset — the same RON the runtime loads. The picker writesVisualStyle::font_asset, resolved through the asset database.
What's in piece 1 — widget tree + layout
Piece 1 is pure-logic: data structures + a deterministic layout function. No GPU, no input, no async. Every test runs headlessly.
Widget— one node in a tree. Holds anid, aLayoutStyle, and aWidgetKind.WidgetKind— what the node is:Leaf { intrinsic: Vec2 }— childless node sized by an intrinsic logical extent. Interactive widgets (label, button, image, slider, …) layer on top of this in later pieces.Stack(Stack)— row or column container with a per-stackgap,direction, andmain_align.Grid(Grid)— equal-cellcols × rowscontainer with agap: Vec2.Anchor(AnchorGroup)— container that positions each child via the child's ownAnchor.
LayoutStyle— sizing, padding, margin, alignment, and (for anchor children) the anchor itself. The same flat struct on every widget.layout(root, viewport, scale) -> LayoutTree— the layout function. Returns aLayoutTreeofLayoutNodes (one per widget, root at index 0) with each node's resolvedrect,content_rect(padding-inset), and the indices of its direct children.
The whole module lives under
engine/src/ui/ and is re-exported through the engine
prelude under disambiguated names (UiSizing, UiAnchor, Widget, …) so it
doesn't collide with the Stage-1 math types.
Building a widget tree
The Widget::row(), Widget::column(), Widget::grid(cols, rows),
Widget::anchor(), and Widget::leaf(intrinsic) constructors plus the
with_* builder methods produce trees declaratively. Builder methods that
only make sense on certain kinds (with_gap on a stack, with_grid_gap on a
grid, with_child on any container) panic with a clear message when called
on the wrong kind — catching author mistakes during construction instead of
producing a silently misshapen UI at layout time.
use oxide_engine::math::Vec2;
use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
let toolbar = Widget::row()
.with_id("toolbar")
.with_gap(8.0)
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Fixed(32.0),
padding: Insets::all(4.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("edit"));
Sizing
Sizing controls how a widget asks to be sized along one axis.
| Variant | Behavior |
|---|---|
Fixed(f32) |
Fixed logical size; multiplied by the layout scale factor. |
Grow(f32) |
Take a share of the parent's leftover space, weighted by f32. Two siblings with Grow(1.0) split evenly; Grow(2.0) next to Grow(1.0) takes 2/3. A non-positive weight contributes nothing. |
FitContent (default) |
Fit the widget's intrinsic content size — leaves use their intrinsic, containers use the recursive content extent. |
The defaults of FitContent × FitContent are intentional: leaves are sized
by what they contain, containers are sized by what they wrap. A root widget
that wants to fill the viewport must opt in with
Sizing::Grow(_) on both axes (or set Fixed extents) — the layout function
makes no special root case.
Padding, margin, alignment
paddingshrinks a widget'scontent_rect, the area inside which children are arranged. Multiplied by the scale factor.marginreserves space outside the widget's rect, so siblings don't touch it. In a stack, margin is added to the child's main-axis footprint before grow accounting.align_horizontal/align_verticalposition a widget within its parent's slot when the widget's resolved size is smaller than the slot. In a stack, cross-axis alignment lets a short child dock to the top, middle, or bottom of its row. (The stack-levelmain_aligndoes the analogous thing on the main axis when there's noGrowchild to absorb leftover space.)
Layout modes
Stack (StackDirection::Row / Column)
- Allocate each child's main-axis size:
Fixed(v)→v * scale,FitContent→ recursive intrinsic measurement,Grow(w)→ reserved (zero first), then assigned a share of leftover space proportional tow.
- Cross-axis sizing happens during the child's own
arrange_in_slotpass:Growfills the parent's cross extent; the other variants leave space the child'salign_*consumes. - With no
Growchild, the stack'smain_align(Start / Center / End) positions the children's combined footprint inside the content rect.
Grid
Equal-cell cols × rows layout. Cell size is computed from the parent's
content rect after subtracting (cols - 1) * gap.x and (rows - 1) * gap.y.
Children fill cells left-to-right, top-to-bottom; extras past cols * rows
are ignored. Within a cell the child's own align_* and sizing decide how it
positions itself — Grow fills the cell, Fixed/FitContent aligns inside
it.
More flexible grids (auto-sized rows/columns, spans) are a follow-up; the equal-cell case covers the Stage-7 bindings preferences page and the Stage-8 settings examples.
Anchor
Each child specifies its own Anchor in LayoutStyle::anchor. The anchor is
two normalized points in [0, 1]² (the anchor rectangle) plus per-corner
offsets in logical pixels:
rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
The Unity/Godot convention applies: the anchor is authoritative. An
anchor child's width, height, margin, and align_* are ignored along
the axes the anchor constrains; padding still applies (it's an
inside-the-rect concern). The
Anchor::FILL, Anchor::TOP, Anchor::TOP_LEFT, Anchor::BOTTOM_RIGHT, …
constants cover the common cases, and Anchor::between(min, max) +
with_offsets(min, max) is the escape hatch.
DPI
All linear inputs (sizing, padding, margin, gap, anchor offsets) are in
logical pixels and multiplied by the scale factor passed to
[layout]. The widget tree is DPI-independent; the layout call is where the
display's scale factor enters. The same widget tree laid out at scale=1.0
inside a 800 × 600 viewport and at scale=2.0 inside a 1600 × 1200 viewport
produces identically proportioned rects, with every dimension doubled —
verified by an integration test.
Widget ids and lookups
WidgetId(pub String) is the author-facing identifier. UI documents ship
their string ids straight through RON ("play", "volume-slider"), so a
visual editor, a hand-edited file, and game code all refer to the same
widget. The empty id ("") is the default and means "anonymous"; multiple
anonymous widgets are allowed and LayoutTree::find rejects lookups by empty
id.
LayoutTree::find(id) is a linear scan — fine for the dozens-of-widgets
trees Stage 8 currently targets; a hash-map index can be added if a profile
ever says it's hot.
RON dual-edit
Every type in the module derives Serialize + Deserialize and round-trips
through RON. Widget::to_ron() produces the pretty-printed canonical form
the editor's UI canvas saves and the runtime loads; Widget::from_ron(text)
parses it. The Stage-8 integration suite verifies that the round-trip
preserves layout — the laid-out trees match — so an external editor or AI
agent can edit the same file the runtime loads.
What's in piece 2 — styling & theming
Visual styling is intentionally orthogonal to layout — layout decides
where a widget is; visual styling decides what it looks like. Adding a
VisualStyle or theme_style to a widget never changes its laid-out rect.
The integration suite verifies this with a paired layout() call before and
after styling.
The data:
VisualStyle— a flat struct ofOption<T>fields:background,foreground,border(color + width),corner_radius,font, andfont_size.Nonemeans inherit;Somemeans override. Every field serializes viaskip_serializing_if = "Option::is_none", so an empty visual style vanishes from RON entirely.Theme—default: VisualStyleplusstyles: BTreeMap<String, VisualStyle>. TheBTreeMap(notHashMap) gives deterministic RON output, important for diff-friendly UI documents and reproducible test snapshots.Widgetgains two fields:visual: VisualStyle(per-instance overrides) andtheme_style: Option<String>(opt-in name into the theme's named map).
The cascade — implemented by Theme::resolve(style_ref, override_with) and
exposed on the widget as Widget::resolve_visual(&theme):
- Start with
theme.default. - If the widget specifies
theme_style: Some(name)and the theme has a matching entry, merge it on top (a missing name is treated as "no contribution", not an error). - Merge the widget's per-instance
visualon top.
Each merge is field-by-field via VisualStyle::merged(self, override_with):
right-hand Some wins, otherwise the left-hand value is kept. The same
primitive will drive runtime state overlays in piece 5 (hover, focus,
press).
FontRef carries family, weight: FontWeight, and italic: bool. The
descriptor stores names, not paths: portable across machines, and the
runtime (piece 3) is free to pick the platform's best match. FontWeight
exposes opentype_value() returning the OpenType 100–900 weight scale.
VisualStyle also has a font_asset: Option<AssetRef<Font>> (Stage 8.5 piece
7): a reference to a specific project font asset under assets/fonts/,
chosen in the editor's UI canvas from the asset browser. When set it takes
precedence over the font descriptor — the renderer resolves the AssetRef
to a loaded face through the asset database (a default-registered
FontLoader makes .ttf/.otf loadable via the AssetServer). None falls
back to the descriptor / theme path. This is the engine's first AssetRef<T>
field and the asset-picker's end-to-end target.
Quick example
use oxide_engine::math::{Color, Vec2};
use oxide_engine::ui::{Border, FontRef, Theme, VisualStyle, Widget};
let theme = Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
font: Some(FontRef::regular("Inter")),
font_size: Some(14.0),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
corner_radius: Some(4.0),
..VisualStyle::EMPTY
},
);
let play = Widget::leaf(Vec2::new(80.0, 24.0))
.with_id("play")
.with_theme_style("button")
.with_visual(VisualStyle {
background: Some(Color::rgb(0.2, 0.4, 0.8)), // primary-button accent
foreground: Some(Color::WHITE),
..VisualStyle::EMPTY
});
let resolved = play.resolve_visual(&theme);
assert_eq!(resolved.foreground, Some(Color::WHITE)); // per-instance wins
assert_eq!(resolved.corner_radius, Some(4.0)); // inherited from "button"
assert_eq!(resolved.font, Some(FontRef::regular("Inter"))); // inherited from default
RON dual-edit
Theme::to_ron / Theme::from_ron round-trip themes through pretty-printed
RON, matching Widget::to_ron from piece 1. BTreeMap-ordered output keeps
named styles alphabetised so diffs are stable. Empty fields (None options,
empty maps, FontWeight::Regular, italic: false) skip serializing — the
default form of any of these structs is () in RON.
What's in piece 3 — text shaping & glyph atlas
The text subsystem lives at oxide_engine::ui::text and splits into three
sub-modules that compose, but each is testable on its own:
font— ownsFont(a thin wrapper aroundab_glyph::FontVec),FontId, andFontStore.Font::rasterize(glyph, size_px)returns aRasterizedGlyphwith an alpha mask + per-glyph bearings + advance.FontStore::insert_with_descriptor(FontRef, Font)indexes a font under a piece-2FontRef, so a theme'sfont: Some(FontRef::bold("Inter"))resolves to aFontIdthe shaper can use.atlas—GlyphAtlas::new(width, height)allocates a single R8 (alpha-only) buffer;get_or_rasterize(GlyphKey, &FontStore)returns the glyph'sAtlasEntry(UV rect + size + bearing + advance), rasterizing and packing on first miss and serving the cache forever after. The packer is a best-fit shelf packer — simple, deterministic, and near-optimal density for the typically-uniform glyph heights of one font at one size. Thedirty()flag tells the piece-4 render pass when the texture needs re-upload.shape—shape(text, style, ¶ms, &fonts)turns a string into aShapedText { lines, size }of positionedShapedGlyphs. Each glyph carries aGlyphKeythe renderer feeds back into the atlas, and apositionat the baseline (not the top-left). Algorithm: greedy line-break at ASCII whitespace, multi-font runs supported viashape_runs(&[TextRun]), alignment withinmax_width(Left / Center / Right), DPI scaling viaShapeParams::scale.
The atlas is the cache
GlyphAtlas keys entries by (FontId, GlyphId, size_px rounded to nearest integer). Every glyph is rasterized exactly once per (font, glyph,
size) triple — a HUD that repaints "HP: 1234 / 1500" every frame
rasterizes the ten ASCII characters one time at startup and then runs
purely on textured quads. The integration suite verifies this:
shaped_hud_text_is_cached_after_one_frame shapes a three-line HUD,
walks every glyph through the atlas twice, and asserts the atlas's
dirty flag stays false on the second pass — i.e., zero new
rasterizations. The library choice (ab_glyph vs fontdue) only affects
the one-time miss cost, not steady-state.
Quick example
use oxide_engine::math::Vec2;
use oxide_engine::ui::text::{
shape, Font, FontStore, GlyphAtlas, ShapeParams, TextAlign, TextStyle,
};
let mut fonts = FontStore::new();
let id = fonts.insert(Font::from_path("/usr/share/fonts/.../Inter-Regular.ttf").unwrap());
let style = TextStyle { font: id, size_px: 14.0 };
let params = ShapeParams {
max_width: Some(300.0),
align: TextAlign::Center,
line_height: 1.4,
scale: 1.0,
};
let shaped = shape("Press F to pay respects", style, ¶ms, &fonts);
let mut atlas = GlyphAtlas::new(1024, 1024);
for line in &shaped.lines {
for glyph in &line.glyphs {
// Render with the atlas's bearing offset; this is exactly the
// call piece 4's overlay pass will make per glyph per frame.
if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
let quad_top_left: Vec2 = glyph.position + entry.bearing;
let _quad_size: Vec2 = entry.size_px;
let _ = (quad_top_left, entry.uv_min, entry.uv_max);
}
}
}
Limitations (deliberate, scoped to piece 3)
- One glyph per
char— no ligatures, no combining marks, no complex- script shaping (Arabic, Devanagari, Thai). The data path is ready for a futurerustybuzz-shaped intermediate; the current shaper just doesn't invoke one. - No BiDi or RTL — text flows left-to-right.
- No hyphenation or character-level break inside an over-wide word.
- ASCII whitespace only (
\tand\rare treated as spaces). - No bold/italic synthesis — each face is a separately-loaded
Font.
Font choice
The engine doesn't bundle a font; piece-3 tests use whichever sans-serif
they find on /usr/share/fonts/ (or /System/Library/Fonts on macOS) via
common_system_font_paths(), skipping with eprintln!("SKIP: …") when no
candidate is present. The default UI font shipped with examples is a
piece-7 decision.
Why ab_glyph
ab_glyph is a TTF parser + rasterizer only. It does not do layout —
which is fine because the shaper above already owns that. With
fontdue we would have gotten line wrapping for free at the cost of
living inside a fixed layout model; with ab_glyph we own every line-
break, kerning, and alignment decision. That control buys us a clean
path to richer features later: rich-text markup, per-character
animation, in-canvas editor caret positioning, and SDF font
rendering — a future follow-up where each glyph is rasterized once
as a signed-distance field and the shader scales it to any size for
free. SDF is on the Stage-8 backlog in PLAN.md; it would
slot in beside ab_glyph without rewriting the shaper.
What's in piece 4a — screen-space overlay render pass
Piece 4 splits the GPU work into two commits — 4a (screen-space, this
piece) and 4b (world-space UI panels in 3D). Both share one render
pass, one shader, one R8 glyph atlas. The split is purely for review
size; the same UiOverlayPass handles both modes via per-batch MVP
matrices.
Two new pieces, both pure-CPU but the second one talks to wgpu:
oxide_engine::ui::paint—paint(&Widget, &LayoutTree, &Theme, &FontStore, scale) -> PaintedFrame. Walks the laid-out tree in parent-then-children order; for each node, resolves the cascadedVisualStyleunder the theme, emits oneDrawCommand::Quadif a background was resolved, and shapes the widget'stext: Option<String>inside itscontent_rectto emit oneDrawCommand::Glyphper laid-out glyph. Pure-logic; tests run without a GPU and most without a font.oxide_engine::render::UiOverlayPass— implementsRenderPassand slots into the Stage-5 pipeline after theForwardPass. ConsumesVec<UiBatch>per frame; each batch pairs an MVP matrix with aPaintedFrame. For piece 4a the host builds one batch withUiBatch::screen_space(painted, target_size)— an orthographic projection from window pixels to NDC with y-down (origin at the top-left).
Vertex format and shader
One vertex format, one fragment path:
struct UiVertex { position: vec2, uv: vec2, color: vec4 } // 32 bytes
The shader (engine/src/render/shaders/ui.wgsl) discriminates "solid quad
vs. glyph quad" by a sentinel UV: uv.x < 0.0 skips the atlas sample. So
a solid red rectangle and a glyph from "Inter" pass through identical
pipeline state and live in the same vertex buffer — no state changes per
primitive, no separate textures. Alpha-blending is on; UI never reads
depth (it overlays).
Atlas lifecycle
Each frame's run:
- Walk every glyph in every batch, calling
GlyphAtlas::get_or_rasterize(key, &fonts)to ensure the entry is cached. Misses rasterize once; hits do nothing. - If the atlas's
dirtyflag is set, re-upload the whole R8 buffer to the GPU texture and clear the flag. Re-uploading the whole atlas (vs. tracking dirty sub-rects) keeps the code simple; the buffer is small (1 MB at 1024×1024) so this is fine. A dirty-region upload is a straightforward follow-up if a profile says it's hot. - For each batch: serialize draw commands into vertices, write the MVP
uniform, set the viewport from
FrameContext::resolved_viewport(), and submit one draw call.
Test strategy
The piece-4 tests live in three places:
engine/src/ui/paint.rs— 5 lib tests verify the CPU paint logic: a solid widget emits one quad at its rect, a text widget emits one glyph command per visible char at the same baseline, layered widgets draw parent-before-child, etc. No GPU required.engine/src/render/ui_pass.rs— 4 headless GPU pixel-readback tests: a 20×20 red quad shows red at its centre and clear-color outside; an empty batch list is a no-op; two quads in one batch both render to their respective rects; the vertex buffer grows when a batch exceeds the initial 4096-vertex capacity.tests/src/lib.rsmod stage8 — 1 integration test runs the whole pipeline:Widget→layout→paint→UiOverlayPass::run→ pixel-readback, then asserts the centred 48×48 red panel is red in the middle and clear-color in the gutter.
Both lib and integration GPU tests skip with eprintln!("SKIP: ...") if
no adapter is available, matching the Stage-4 pattern.
Wiring it into an app
use oxide_engine::math::Color;
use oxide_engine::render::{RenderPipeline, UiBatch, UiOverlayPass};
use oxide_engine::ui::{layout, paint, FontStore, Theme, Widget};
# fn build_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) {
let mut pipeline = RenderPipeline::forward(device, format);
let ui_pass = UiOverlayPass::new(device, format);
pipeline.add_pass("ui", ui_pass);
# }
# fn each_frame(
# ui_pass: &mut UiOverlayPass,
# document: &Widget,
# theme: &Theme,
# fonts: &FontStore,
# viewport: oxide_engine::math::Rect,
# target_size: (u32, u32),
# ) {
let tree = layout(document, viewport, 1.0);
let painted = paint(document, &tree, theme, fonts, 1.0);
ui_pass.set_batches(vec![UiBatch::screen_space(painted, target_size)]);
// pipeline.render(&mut frame); — at next frame.
# }
What's in piece 4b — world-space UI panels
oxide_engine::ui::UiPanel is a pure-data holder: a Widget tree plus two
sizes — pixel_size (the resolution the UI is laid out at) and
world_size (the panel's physical dimensions in world units). It does
not own the panel's Transform; that lives on the entity that hosts
the panel (eventually a hecs component), so the same panel can be
duplicated across many entities with different placements.
UiBatch::world_space(painted, pixel_size, world_size, &panel_transform, view_projection) composes a single MVP that the existing piece-4a pass
uses unchanged:
mvp = view_projection
* panel_transform // world placement
* scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (y-flip)
* translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
A pixel at (0, 0) in the painted frame lands at the panel's top-left
corner in world space; a pixel at pixel_size lands at the bottom-right.
Same pipeline, same shader, same atlas — only the MVP differs.
UiPanel::build_batch(theme, fonts, &panel_transform, view_projection)
is the convenience that lays out + paints + builds the batch in one call.
Hosts that want finer control compose the same three steps by hand.
Overlay semantics
World-space panels in piece 4b render as overlays: no depth test, no depth write — they draw on top of whatever's in the colour target. That keeps the implementation simple and matches the common "always-visible" use case (player nameplates, mission markers, editor canvas previews).
A future depth-aware mode (where a panel behind a wall is properly hidden) is in PLAN.md's Stage-8 backlog and slots in by attaching the depth target to a second pass of the same pipeline.
Tests
- 4 lib tests on
UiPanel:build_batchreturnsNoneon zeropixel_size, succeeds on a valid panel, the panel round-trips through RON for dual-edit, and the identity-MVP sanity check maps pixel(0, 0)to world(-world.x/2, +world.y/2)(verifying the y-flip). - 1 GPU pixel-readback lib test (
world_space_panel_renders_inside_its_projected_region): a 2 m × 2 m red panel at the origin under a 60° camera 3 m away, asserts the framebuffer centre is red and corners stay clear. - 1 integration test (
ui_panel_in_3d_renders_under_perspective_camera): exercises the fullUiPanel::build_batch→UiOverlayPasspath end to end with a real perspective camera and a pixel-readback assertion.
Quick example
use oxide_engine::math::{Color, Transform, Vec2, Vec3};
use oxide_engine::render::{Camera, UiBatch, UiOverlayPass};
use oxide_engine::ui::{FontStore, Theme, UiPanel, VisualStyle, Widget};
# fn each_frame(pass: &mut UiOverlayPass, panel: &UiPanel) {
let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0);
let view_transform = Transform::looking_at(Vec3::new(0.0, 1.5, 4.0), Vec3::ZERO, Vec3::Y);
let view_projection = camera.view_projection(16.0 / 9.0, &view_transform);
// Where the panel sits in the world. Treat as if it were a Transform
// component on the entity hosting the panel.
let panel_transform = Transform::default();
let theme = Theme::new();
let fonts = FontStore::new();
let batch = panel
.build_batch(&theme, &fonts, &panel_transform, view_projection)
.expect("valid panel");
pass.set_batches(vec![batch]);
// pipeline.render(&mut frame); — at next frame.
# }
What's in piece 5 — input routing
The UI must consume input before the game (PLAN.md): clicking a button
shouldn't also fire the game action bound to the same mouse button.
oxide_engine::ui::routing gives the host one object that does this
end-to-end:
use oxide_engine::prelude::*;
use oxide_engine::ui::Router;
# fn each_frame(router: &mut Router, tree: &UiLayoutTree, input: &InputState) {
let frame = router.process(tree, input);
if !frame.captured_mouse {
// game receives mouse this frame
}
if !frame.captured_keyboard {
// game receives keys this frame
}
for event in &frame.events {
// piece 6 will dispatch each event to the matching widget's callback
}
# }
Hit-test
hit_test(&LayoutTree, point) -> Option<&LayoutNode> walks the laid-out
nodes in reverse order — the same order as paint (parents-then-
children, earlier siblings before later ones), so the topmost-drawn
widget is the first one tested. Anonymous widgets
(WidgetId::default()) are skipped so a decorative container doesn't
block clicks reaching the button inside it.
State machine
The Router persists three pieces of state across frames:
- hovered — recomputed each frame from the cursor + hit-test.
- focused — set when the cursor presses over a widget; cleared when the cursor presses outside any widget. Survives subsequent hover changes so a focused text input keeps focus while the cursor moves.
- pending presses — per-button, the widget that received the
most-recent unreleased press. A press → release on the same
widget emits
Clicked. Drag-off then release cancels the click.
Events
RouterFrame.events: Vec<RouterEvent> collects everything that
happened: Hovered / Unhovered, Pressed / Released / Clicked
(per mouse button), FocusGained / FocusLost. Piece 6 will dispatch
each event to per-widget callbacks; piece 5 is purely the state machine
producing the event list.
Tests
- 13 lib tests cover hit-test (topmost wins, anonymous skipped, outside-root → None, padding gutter resolves to parent), hover/ unhover/swap-on-move, press → focus, press + release on the same widget → click, drag-off cancels click, press outside clears focus, captured-flag transitions, and cursor-unset → no hover.
- 1 integration test exercises the full hover → press → release →
click → move → press-outside-loses-focus sequence end-to-end with a
synthetic
InputState.
All tests are pure-CPU; no GPU, no font, no window.
What's deliberately not in piece 5
- Keyboard focus navigation (Tab / arrow keys to move focus) — a small follow-up on top of the existing focus state.
- Per-widget callbacks — piece 6.
- World-space hit-test — clicking through a 3D panel needs a
ray-cast and an inverse-MVP. A follow-up that slots in by adding a
Router::hit_test_world(ray, &UiPanel, &Transform)helper.
What's in piece 6 — events + data binding
Piece 6 takes the immediate-mode stance (same as Bevy UI and egui):
no callback storage, no Rc<RefCell<...>> for state, no lifetime
gymnastics — the host reads the RouterFrame each frame and acts
directly.
Events: immediate-mode queries on RouterFrame
The piece-5 RouterFrame already carries the event list. Piece 6 adds
typed query methods that game code calls directly:
# fn each_frame(frame: oxide_engine::ui::RouterFrame) {
use oxide_engine::winit::event::MouseButton;
if frame.clicked_left("play") {
// start_game();
}
if frame.clicked("save", MouseButton::Right) {
// open_save_menu();
}
if frame.hovered_in("tooltip-target") {
// show_tooltip();
}
if frame.focus_gained("volume_slider") {
// ...
}
# }
The seven query methods — clicked, clicked_left, pressed,
released, hovered_in, hovered_out, focus_gained, focus_lost
— each take a widget id and (where applicable) a MouseButton, and
return bool. They scan the frame's event list, so the cost is linear
in the number of events emitted that frame — typically a handful.
Data binding: Widget::value: Option<WidgetValue>
Every widget can carry typed state — a checkbox's bool, a slider's
float, a text input's string — independent of its kind. The
WidgetValue enum has variants Bool(bool) / Int(i64) /
Float(f64) / Text(String), plus From-impls for bool, i32,
i64, f32, f64, &str, and String.
Per-widget access uses Widget::value(&id) and Widget::set_value(&id, v) — both walk the subtree to find the widget by id:
use oxide_engine::ui::{Widget, WidgetValue};
# fn pull_then_push(root: &mut Widget, audio_volume: &mut f32) {
// Pull game state into the widget tree (typically at the start of frame).
root.set_value(&"volume".into(), *audio_volume);
// ... user interacts, slider widget updates its own value ...
// Push the widget tree's value back into game state (at end of frame).
if let Some(v) = root.value(&"volume".into()).and_then(|v| v.as_float()) {
*audio_volume = v as f32;
}
# }
For values that don't change between frames (e.g., a label's string), no binding is needed — set it once.
Why immediate-mode
The persistent-callback alternative (each widget owns a
Box<dyn FnMut(...)>) forces every callback to either:
- own its game state via
Rc<RefCell<...>>(verbose, costs every read), or - borrow game state for
'static(impossible), or - defer to a queue (the same shape as immediate-mode, but indirected).
Immediate-mode skips all three: the widget tree is data, not a network of callbacks. The host's main loop is the dispatcher; the piece-6 queries are just convenient predicates over the event list.
What's deliberately not in piece 6
- Typed bindings helper (
Bindings<T>that registers per-field getter/setter pairs and runs them automatically) — adds aBox<dyn>abstraction over what's currently two lines of host code. Will land alongside piece-7's settings example if the boilerplate becomes painful. - Per-widget keyboard event delivery (text input handling, hotkey registration) — needs a focused-widget event-routing pass on top of the piece-5 focus state. Either piece-7 or a follow-up.
Tests
- 6 lib tests on
WidgetValuecover accessor matching,Fromconversions for every primitive, and RON round-trip for each variant. - 4 lib tests on
Widget:find_by_id/find_by_id_mutwalk the subtree,set_valueupdates a descendant by id,with_valuebuilder works, the value round-trips throughWidget's own RON. - 2 lib tests on
RouterFrame: query methods return true for matching events, false for non-matching, across every event variant. - 1 integration test (
settings_widget_tree_round_trips_game_state_each_frame): pulls game state into a settings panel, simulates user interaction + an Apply click, pushes the widget values back into game state, and asserts the round-trip is exact.
What's in piece 8 — examples/ui_hud
examples/src/bin/ui_hud.rs is the second runnable Stage-8 example and
the first to composite the UI over a 3D scene. It reuses the
hello_mesh scene (spinning cube + sphere + ground plane through the
Stage-4 ForwardPass) and draws a HUD on top with a screen-space
UiOverlayPass.
Compositing two passes on one surface
The window runner clears the surface to the configured clear color
before render. Both the forward pass and the UI overlay then use
LoadOp::Load for their color attachment, so each draws over whatever
is already there:
pipeline.render(&mut frame)runs the forward pass — 3D geometry plus its own depth buffer (cleared each call).ui_pass.run(&mut frame)runs the overlay — no depth, alpha blending — so the HUD sits on top of the 3D image.
The host owns the UiOverlayPass separately from the RenderPipeline
(rather than add_pass-ing it) because the overlay needs set_batches
mutated every frame and the pipeline consumes pass ownership. Both
passes share the same FrameContext, so the example builds the 3D
objects and the painted HUD, then calls the two runs back to back.
Corner anchoring
Each HUD element is an anchor child of a full-screen anchor root. A
corner-pinned, fixed-size widget is expressed as a corner Anchor
constant plus offsets that define its box — e.g. a top-left chip is
Anchor::TOP_LEFT.with_offsets((M, M), (M + W, M + H)), and a centred
crosshair is Anchor::between((0.5, 0.5), (0.5, 0.5)).with_offsets(...).
The crosshair's two bars are themselves anchor children spanning one
axis and pinned thin on the other.
Demonstrating the atlas cache
The HP and Ammo values animate every frame (HP oscillates down then up;
Ammo counts down as if firing, reloading at 0). The digits change
constantly, but the glyph atlas only ever rasterizes each character
once — after the digits 0–9 and the static label text have been
seen, the atlas stops growing and every later frame is a pure cache hit
(no rasterize, no GPU re-upload). The example logs each atlas growth and
the moment it reaches steady state, via two accessors added to the pass:
pass.atlas_glyph_count(); // distinct glyphs cached so far
pass.atlas_dirty(); // grew-this-run flag (false in steady state)
The atlas_caches_glyphs_and_reaches_steady_state GPU test in
render::ui_pass proves this property automatically: it draws the ten
digits one per frame (asserting the count grows by one each time), then
re-draws a cached digit and asserts the count holds and the dirty flag
stays clear.
Run it: cargo run -p oxide-examples --bin ui_hud (Esc quits).
What's coming in the rest of Stage 8
- Piece 9 — Editor UI canvas. A new editor panel for visually
authoring
Widget/UiPaneldocuments: a drag-from widget palette, a canvas showing the document at target size with drag-resize handles, a property inspector forLayoutStyle/VisualStyle/text/value, RON save/load round-tripping the same format the runtime loads, and a live preview rendered through the actualUiOverlayPass(not egui). Likely splits into 9a (canvas + palette + inspector) and 9b (live preview + drag/resize handles).
The piece-1 data structures already accommodate the editor canvas: a
document is just a Widget tree, the inspector edits the same reflected
style structs the runtime uses, and the preview reuses the exact paint +
overlay pipeline the game ships with.