# 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](#whats-in-piece-1--widget-tree--layout). - **Piece 2 — styling & theming** (per-widget visual overrides, named-style themes, RON cascade). See [below](#whats-in-piece-2--styling--theming). - **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](#whats-in-piece-3--text-shaping--glyph-atlas). - **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](#whats-in-piece-4a--screen-space-overlay-render-pass). - **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](#whats-in-piece-4b--world-space-ui-panels). - **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](#whats-in-piece-5--input-routing). - **Piece 6 — events + data binding** (immediate-mode queries on `RouterFrame` — `clicked_left("play")` etc. — plus typed `WidgetValue`s on the tree so game state and widget state round-trip each frame). See [below](#whats-in-piece-6--events--data-binding). - **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](#whats-in-piece-8--examplesui_hud). - **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`](../engine/src/ui/widget.rs) 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`](../engine/src/ui/visual.rs), resolved through the [asset database](assets.md). ## 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`](#widget-ids), a [`LayoutStyle`](#layoutstyle), and a [`WidgetKind`](#widgetkinds). - **`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`](#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 `LayoutNode`s (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/`](../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. ```rust 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: ```text 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` 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. - **`Theme`** — `default: VisualStyle` plus `styles: BTreeMap`. 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` (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 100–900 weight scale. `VisualStyle` also has a `font_asset: Option>` (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](assets.md) (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` field and the asset-picker's end-to-end target. [`AssetRef`]: ../engine/src/asset/database.rs ### Quick example ```rust 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. - **`atlas`** — `GlyphAtlas::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. - **`shape`** — `shape(text, style, ¶ms, &fonts)` turns a string into a `ShapedText { lines, size }` of positioned `ShapedGlyph`s. 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 ```no_run 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 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](../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 cascaded [`VisualStyle`](#whats-in-piece-2--styling--theming) under the theme, emits one `DrawCommand::Quad` if a background was resolved, and shapes the widget's `text: Option` 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`](render-pipeline.md) and slots into the Stage-5 pipeline *after* the `ForwardPass`. Consumes `Vec` 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: ```text 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: `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 ```rust,no_run 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: ```text 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_batch` → `UiOverlayPass` path end to end with a real perspective camera and a pixel-readback assertion. ### Quick example ```rust,no_run 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: ```rust,no_run 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` 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>` 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: ```rust,no_run # 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` 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: ```rust,no_run 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`) forces every callback to either: - own its game state via `Rc>` (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` that registers per-field getter/setter pairs and runs them automatically) — adds a `Box` 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 `run`s 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: ```rust 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.