9eead719b0
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>
861 lines
38 KiB
Markdown
861 lines
38 KiB
Markdown
# 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 100–900 weight scale.
|
||
|
||
`VisualStyle` also has a `font_asset: Option<AssetRef<Font>>` (Stage 8.5 piece
|
||
7): a reference to a **specific project font asset** under `assets/fonts/`,
|
||
chosen in the editor's UI canvas from the asset browser. When set it takes
|
||
precedence over the `font` descriptor — the renderer resolves the [`AssetRef`]
|
||
to a loaded face through the [asset database](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, ¶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<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.
|