Files
Oxide/docs/ui.md
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

38 KiB
Raw Blame History

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 (paint turns a laid-out tree into draw commands; UiOverlayPass batches them through wgpu with one R8 atlas and one alpha-blended pipeline). See below.
  • Piece 4b — world-space UI panels (UiPanel carries a Widget tree + 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 (Router walks the LayoutTree against the Stage-7 InputState, 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 RouterFrameclicked_left("play") etc. — plus typed WidgetValues 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 with cargo 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-4 ForwardPass renders the spinning cube/sphere/ plane, then a screen-space UiOverlayPass draws 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 with cargo 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 UiPanel document visually: a widget-tree view (positional WidgetPath addressing), 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 a ui/ asset — the same RON the runtime loads. The picker writes VisualStyle::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 an id, a LayoutStyle, and a WidgetKind.
  • 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-stack gap, direction, and main_align.
    • Grid(Grid) — equal-cell cols × rows container with a gap: Vec2.
    • Anchor(AnchorGroup) — container that positions each child via the child's own Anchor.
  • 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 a LayoutTree of LayoutNodes (one per widget, root at index 0) with each node's resolved rect, 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

  • padding shrinks a widget's content_rect, the area inside which children are arranged. Multiplied by the scale factor.
  • margin reserves 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_vertical position 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-level main_align does the analogous thing on the main axis when there's no Grow child to absorb leftover space.)

Layout modes

Stack (StackDirection::Row / Column)

  1. 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 to w.
  2. Cross-axis sizing happens during the child's own arrange_in_slot pass: Grow fills the parent's cross extent; the other variants leave space the child's align_* consumes.
  3. With no Grow child, the stack's main_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 of Option<T> fields: background, foreground, border (color + width), corner_radius, font, and font_size. None means inherit; Some means override. Every field serializes via skip_serializing_if = "Option::is_none", so an empty visual style vanishes from RON entirely.
  • Themedefault: VisualStyle plus styles: BTreeMap<String, VisualStyle>. The BTreeMap (not HashMap) gives deterministic RON output, important for diff-friendly UI documents and reproducible test snapshots.
  • Widget gains two fields: visual: VisualStyle (per-instance overrides) and theme_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):

  1. Start with theme.default.
  2. 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).
  3. Merge the widget's per-instance visual on 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 100900 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 — owns Font (a thin wrapper around ab_glyph::FontVec), FontId, and FontStore. Font::rasterize(glyph, size_px) returns a RasterizedGlyph with an alpha mask + per-glyph bearings + advance. FontStore::insert_with_descriptor(FontRef, Font) indexes a font under a piece-2 FontRef, so a theme's font: Some(FontRef::bold("Inter")) resolves to a FontId the shaper can use.
  • atlasGlyphAtlas::new(width, height) allocates a single R8 (alpha-only) buffer; get_or_rasterize(GlyphKey, &FontStore) returns the glyph's AtlasEntry (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. The dirty() flag tells the piece-4 render pass when the texture needs re-upload.
  • shapeshape(text, style, &params, &fonts) turns a string into a ShapedText { lines, size } of positioned ShapedGlyphs. Each glyph carries a GlyphKey the renderer feeds back into the atlas, and a position at the baseline (not the top-left). Algorithm: greedy line-break at ASCII whitespace, multi-font runs supported via shape_runs(&[TextRun]), alignment within max_width (Left / Center / Right), DPI scaling via ShapeParams::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, &params, &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 future rustybuzz-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 (\t and \r are 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::paintpaint(&Widget, &LayoutTree, &Theme, &FontStore, scale) -> PaintedFrame. Walks the laid-out tree in parent-then-children order; for each node, resolves the cascaded VisualStyle under the theme, emits one DrawCommand::Quad if a background was resolved, and shapes the widget's text: Option<String> inside its content_rect to emit one DrawCommand::Glyph per laid-out glyph. Pure-logic; tests run without a GPU and most without a font.
  • oxide_engine::render::UiOverlayPass — implements RenderPass and slots into the Stage-5 pipeline after the ForwardPass. Consumes Vec<UiBatch> per frame; each batch pairs an MVP matrix with a PaintedFrame. For piece 4a the host builds one batch with UiBatch::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:

  1. 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.
  2. If the atlas's dirty flag 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.
  3. 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.rs mod stage8 — 1 integration test runs the whole pipeline: WidgetlayoutpaintUiOverlayPass::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_batch returns None on zero pixel_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 full UiPanel::build_batchUiOverlayPass path 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 a Box<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 WidgetValue cover accessor matching, From conversions for every primitive, and RON round-trip for each variant.
  • 4 lib tests on Widget: find_by_id / find_by_id_mut walk the subtree, set_value updates a descendant by id, with_value builder works, the value round-trips through Widget'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:

  1. pipeline.render(&mut frame) runs the forward pass — 3D geometry plus its own depth buffer (cleared each call).
  2. 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 09 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 / UiPanel documents: a drag-from widget palette, a canvas showing the document at target size with drag-resize handles, a property inspector for LayoutStyle / VisualStyle / text / value, RON save/load round-tripping the same format the runtime loads, and a live preview rendered through the actual UiOverlayPass (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.