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>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+860
View File
@@ -0,0 +1,860 @@
# 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<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.
- **`Theme`** — `default: 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](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<T>`
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, &params, &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, &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](../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<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`](render-pipeline.md) 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:
```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<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:
```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<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:
```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<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 `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.