Compare commits
1 Commits
v0.1.0
..
9eead719b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eead719b0 |
+1
-1
@@ -27,6 +27,6 @@ Thumbs.db
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Personal scratch notes (the engine documentation lives on the wiki, not here)
|
||||
# Personal scratch notes (tracked docs live in docs/ and are NOT ignored)
|
||||
*.local.md
|
||||
/scratch/
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Kept as the work happens, not assembled at release time.
|
||||
|
||||
Oxide is in its **alpha phase** — `0.x.x`, on `main`. Phase 1 runs to Stage 16
|
||||
(game export to Linux and Windows), and that milestone is what `1.0.0` means.
|
||||
Until then there is no `dev` channel: alpha *is* main.
|
||||
|
||||
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.1.0 — 2026-08-08
|
||||
|
||||
The first tagged version. It is not the first work: Stages 0–9 are complete and
|
||||
Stage 10 (scripting, live reload, editor terminal) is complete on `main` bar the
|
||||
editor-UX batch on `dev`. This entry marks the point the project started being
|
||||
versioned, not the point it started.
|
||||
|
||||
### Added
|
||||
|
||||
- Stages 0–4: workspace and build, math and core primitives, windowing and the
|
||||
app loop, the render context, the scene graph, and forward rendering.
|
||||
- Stage 5, the engine core framework: modules and scheduling, layers/tags with
|
||||
the shared `LayerMask`, the reflection/type registry, the asset server, and
|
||||
the data-driven render pass pipeline.
|
||||
- Stage 6, the editor framework: docking shell, project system, settings
|
||||
framework, file watching, and the module → editor extension API.
|
||||
- Stage 7: the input system — `InputState`, named remappable actions, axes.
|
||||
- Stage 8: the in-game UI system (widgets, layout, theming, text, routing), with
|
||||
the GUI tail still open. Stage 8.5 prefabs and 8.7 editor play mode.
|
||||
- Stage 9: physics on rapier3d, with `LayerMask`-filtered collision.
|
||||
- Stage 10: scripting on rhai — the `Script` component, the sandboxed engine,
|
||||
live reload, the editor console and PTY terminal, and the richer script API.
|
||||
|
||||
### Changed
|
||||
|
||||
- The documentation left the repository. `docs/`, `PLAN.md`, `HANDOFF.md` and
|
||||
`CLAUDE.md` are now the [wiki](https://git.houmeres.sk/Houmeres/Oxide/wiki),
|
||||
and the repository's history was rewritten in the same pass so they are not
|
||||
present at any commit. `README.md` and this file stay here, because they ship
|
||||
with the software.
|
||||
- Doc comments and `README.md` that pointed at those files now point at the
|
||||
wiki.
|
||||
|
||||
### Note on history
|
||||
|
||||
Any clone of this repository taken before 2026-08-08 has a different history and
|
||||
should be discarded rather than merged.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Oxide Engine — Claude Code Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Oxide** is a general-purpose 3D game engine written in Rust. It is built to make **any** 3D game,
|
||||
scaling from stylized low-poly (e.g. with a VCR/CRT post filter) to realistic graphics, and to **ship
|
||||
only what each game uses**. It ships with an **in-engine editor** (`oxide-editor`) developed alongside
|
||||
the engine and gaining capabilities at each stage.
|
||||
|
||||
The work is split into two phases (see `PLAN.md`):
|
||||
- **Phase 1 — general-purpose engine (Stages 0–16):** everything needed to build *and export* any
|
||||
game. Stage 16 (game export to Linux + Windows) is the milestone.
|
||||
- **Phase 2 — built-in modules (Stages 17+):** optional, self-contained capabilities (ray-traced
|
||||
audio, developer console, procedural toolkit, terrain, open world, pathfinding/AI, water), each a
|
||||
feature-gated **module** built on Phase-1 systems.
|
||||
|
||||
Simulation, open world, and procedural generation are **capabilities the engine supports through
|
||||
modules**, not design drivers. The guiding idea is **build tools, not games**.
|
||||
|
||||
### Core Feature Goals (Phase 1 — the general-purpose engine)
|
||||
- Scene graph and entity management
|
||||
- **Engine core framework**: a **module/plugin system** (compile-time feature-gated crates + a
|
||||
runtime `Module` trait + `rhai` script modules), system scheduling, a **layers & tags** system
|
||||
(`LayerMask` for physics/render/query filtering + gameplay tags), a central **asset server** with
|
||||
handles, a **reflection/type registry**, and a **data-driven render pass pipeline**
|
||||
- **Editor framework & project system**: top menu, dockable panels, undo/redo command stack, a
|
||||
**module→editor extension API** (modules add panels/menus/tools/inspectors), a **settings/
|
||||
preferences framework** (engine + editor + per-module settings, enable/disable modules), and a
|
||||
**project system** (create/open/save projects, file watching)
|
||||
- **Comprehensive input mapping**: map any key to press/up/down, *and* named remappable actions
|
||||
(e.g. a `Jump` action defaulting to `Space` that game code references by name while players rebind
|
||||
the physical key in settings)
|
||||
- **Comprehensive in-game UI system**: widgets, layout, theming, text, and input routing that ship
|
||||
inside exported games (distinct from the editor's `egui`); UI documents are serializable and
|
||||
dual-editable, authored in a visual editor canvas
|
||||
- **Comprehensive** physics simulation (`rapier3d`: colliders, joints, scene queries, sensors,
|
||||
layer-filtered collision/triggers, kinematic character controller — not a thin wrapper)
|
||||
- **Scripting + live reload + in-editor terminal**: game scripts are watched and hot-reloaded; the
|
||||
editor hosts a terminal that can run tools and AI agents which edit game code live
|
||||
- Animation system · particle engine · shader support (simple → advanced, scalable fidelity)
|
||||
- **Standard audio**: mixer/spatial system (ray-traced spatial sound is a Phase-2 module)
|
||||
- **Dual-editable types**: every engine object/component type (`Transform`, `Script`, materials,
|
||||
colliders, …) is editable from both the editor UI and from scripts/code via one reflected/
|
||||
serializable representation, so any editor or AI agent can author game code and data in real time
|
||||
- Built-in content kit (prototyping primitives, shaders, character controller) for fast starts
|
||||
- **Game export** to standalone Linux + Windows binaries (ships only the modules a project uses)
|
||||
- **In-engine editor** (first-class; not an afterthought)
|
||||
|
||||
### Built-in Modules (Phase 2 — optional, feature-gated)
|
||||
- Ray-traced spatial audio (wave propagation, occlusion, reverb)
|
||||
- Developer console & cheats (drop-in dev interface; compiled out of release)
|
||||
- Procedural toolkit (noise + composable modifier stack — tools, not a fixed world generator)
|
||||
- Terrain system (generate, sculpt, paint splat layers, scatter foliage/objects)
|
||||
- Open world support (streaming, LOD, chunking)
|
||||
- Pathfinding & NPC AI (navmesh, agents, behavior trees/state machines, perception)
|
||||
- Water (rendering + buoyancy/swim/flow mechanics)
|
||||
|
||||
Anyone can write a module; each ships docs for **both** using it **and** authoring one.
|
||||
|
||||
### Platform & Targets
|
||||
- **Linux is the primary platform, on both Wayland and Xorg (X11)** — keep both backends working at
|
||||
every stage (`winit` `wayland` + `x11` features).
|
||||
- **Windows support is added later**, once the engine is substantial; avoid Linux-only assumptions.
|
||||
- **Game export targets both Linux and Windows** standalone binaries (editor runs on Linux and can
|
||||
cross-export). See PLAN.md Stage 16.
|
||||
|
||||
## Development Philosophy
|
||||
|
||||
- Build in stages; each stage must be tested and stable before the next begins
|
||||
- **Build tools, not games** — ship composable building blocks; genre-specific behavior lives in
|
||||
game code or optional modules
|
||||
- **Ship only what's used** — subsystems are feature-gated modules; an exported game compiles in only
|
||||
the modules it registers
|
||||
- **Scalable fidelity** — the data-driven render pass pipeline lets a project run anything from a flat
|
||||
low-poly/stylized look to a full realistic stack, paying only for the passes it enables
|
||||
- **Modules are the primary extension point** — a module registers engine logic *and* editor UI *and*
|
||||
its own settings through one documented API; anyone (including AI agents) can write one
|
||||
- Every system should be composable and independently usable
|
||||
- Prefer correctness and clarity over premature optimization
|
||||
- Keep public APIs minimal — internal complexity is fine, external surface should be clean
|
||||
- No feature creep between stages; additions go into the backlog for later stages
|
||||
- The editor (`oxide-editor`) grows with the engine — each stage adds editor support for new systems
|
||||
through the Stage-6 editor framework (panels, menus, undo stack, settings pages)
|
||||
|
||||
## Documentation & File Maintenance
|
||||
|
||||
- **Always update** `CLAUDE.md`, `PLAN.md`, `README.md`, and `.gitignore` when project rules, goals, stage definitions, or project structure changes
|
||||
- `PLAN.md` is the authoritative roadmap — keep it accurate and up-to-date
|
||||
- `README.md` must reflect the current build/install instructions and feature list at all times. Keep it a **short overview** — detailed documentation belongs in `docs/`, not the README
|
||||
- `.gitignore` must be updated whenever new tools, output formats, or file types are introduced that should not be tracked (e.g. new build targets, generated files, editor temp files)
|
||||
- Do not let these files become stale after structural or process changes
|
||||
|
||||
### Full documentation in `docs/`
|
||||
|
||||
- The `docs/` directory holds the full documentation of the engine — both **usage** (how to call each system) and **inner workings** (how/why it works). The project is too large to fit this in `README.md`.
|
||||
- **Write documentation as you work, not after.** A stage is not complete until its systems are documented under `docs/`.
|
||||
- One topic per file; link between files rather than duplicating. `docs/README.md` is the documentation index — add new docs to it.
|
||||
- When an API changes, update the affected doc and its code snippets **in the same change** so docs never drift from the code.
|
||||
- Current docs: `architecture.md`, `conventions.md`, `getting-started.md`, `development.md`, and per-system references (e.g. `math.md`, `windowing.md`, `render-context.md`, `scene.md`).
|
||||
|
||||
## Packaging, Installation & Export
|
||||
|
||||
- The project must be compilable to a Linux installable package
|
||||
- `install.sh` at the repo root builds in release mode and installs the editor binary plus assets to the system (`/usr/local` by default, overridable via `PREFIX`)
|
||||
- Keep `install.sh` updated whenever new binaries or assets are added
|
||||
- The installed binary name is `oxide-editor`
|
||||
- The editor must run on Linux under **both Wayland and Xorg**
|
||||
- A later stage adds a **Windows build** of the editor/engine and **game export** to standalone
|
||||
Linux *and* Windows binaries (the exported game links the engine runtime without the editor) — see
|
||||
PLAN.md Stage 16; keep packaging docs current when that lands
|
||||
|
||||
## Language & Tooling
|
||||
|
||||
- Language: Rust (stable toolchain)
|
||||
- Build: Cargo workspace (`engine/`, `editor/`, `examples/`, `tests/`)
|
||||
- Graphics: `wgpu` (portability across Vulkan, Metal, DX12)
|
||||
- Physics: `rapier3d`
|
||||
- Math: `glam`
|
||||
- ECS: `hecs` (preferred lightweight approach)
|
||||
- Editor UI: `egui` (integrated into `oxide-editor`)
|
||||
- Scripting: `rhai` (preferred — embeddable, sandboxed); file watching via `notify` for live reload
|
||||
- Audio: standard system via `kira`/`rodio`; ray-traced model built on the engine's own ray casts
|
||||
- Windowing backends: `winit` with both `wayland` and `x11` enabled (Linux); Win32 later
|
||||
|
||||
## Repository
|
||||
|
||||
- Remote: `https://git.houmeres.sk/Houmeres/Oxide.git`
|
||||
- Local: `/home/homer/Oxide`
|
||||
|
||||
## Git Workflow & Branching
|
||||
|
||||
Two long-lived branches: **`dev`** (integration) and **`main`** (stable). Full
|
||||
detail in `docs/development.md`; the rules Claude follows:
|
||||
|
||||
- **Auto-commit and push to `dev`** every new piece of work that can be **fully
|
||||
verified automatically** (it builds and its unit/integration/fuzz tests and
|
||||
benchmarks pass). Do this as soon as the work is complete and green — no need
|
||||
to ask first for these.
|
||||
- **Manual-test gate before `main`.** If a change cannot be fully verified by
|
||||
automated tests — anything involving the GUI, rendering, audio, input feel, or
|
||||
otherwise needing a human to run the engine and observe it — push it to `dev`
|
||||
only, then ask the maintainer to test it. Promote to `main` **only after the
|
||||
maintainer explicitly approves** it works.
|
||||
- **Fully-automated changes may go to both `dev` and `main` together**, because
|
||||
the passing automated suite is the sign-off (e.g. pure-logic modules like the
|
||||
math system).
|
||||
- Decision rule: *"Can a test prove this works without a human looking at it?"*
|
||||
Yes → eligible for `main`. No → stop at `dev` and request manual testing. When
|
||||
in doubt, treat it as needing manual testing.
|
||||
- Always run the local gate before committing:
|
||||
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
|
||||
- End AI-assisted commit messages with the Claude co-author trailer.
|
||||
|
||||
## Working with Claude
|
||||
|
||||
- Read `PLAN.md` for the full staged roadmap before starting any new stage
|
||||
- Each stage has defined deliverables and test criteria — do not skip testing phases
|
||||
- When implementing a system, prefer small focused modules over large monolithic files
|
||||
- Breaking changes between stages are acceptable; backward compatibility is not a goal during early stages
|
||||
- After completing work that changes project structure, goals, or process, update `CLAUDE.md`, `PLAN.md`, and `README.md` before considering the task done
|
||||
+1
-1
@@ -28,7 +28,7 @@ hecs = "0.10"
|
||||
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit
|
||||
# backends explicitly enabled so neither can be dropped by a default-feature
|
||||
# change. (On by default today; listing them makes the contract explicit —
|
||||
# see the wiki Roadmap, "Platform & Target Strategy".)
|
||||
# see PLAN.md "Platform & Target Strategy".)
|
||||
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
|
||||
wgpu = "29"
|
||||
pollster = "0.4"
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
# Oxide — Session Handoff
|
||||
|
||||
Drop this file into a new session and say "continue from HANDOFF.md". It
|
||||
captures where we are, how we work, and exactly what to do next. Authoritative
|
||||
roadmap is `PLAN.md`; project rules are `CLAUDE.md`; this is the "current
|
||||
state + how to continue" snapshot.
|
||||
|
||||
_Last updated: 2026-06-17. **`main` and `dev` are aligned.** **Stage 9 — Physics
|
||||
is ✅ COMPLETE on `main`.** **Stage 10 — Scripting, Live Reload & Editor Terminal:
|
||||
core ✅ on `main`** (all eye-checked & approved 2026-06-17 in the `untitled`
|
||||
project): the `oxide-script` crate (`Script` + `.rhai` loader + sandboxed
|
||||
`ScriptEngine`), the `init`/`update(dt)` lifecycle via `ScriptHost`, the
|
||||
`Vec3`/transform engine API, live reload (`examples/script_spin`), editor
|
||||
integration (`Script` addable; play-loop shares the editor
|
||||
`AssetServer`/`AssetDatabase` so a live `.rhai` edit updates a *playing* scene),
|
||||
the **Console** (captures the `log` stream — script `print`/errors), a **command
|
||||
terminal** (`$` → `sh -c`), and an **interactive PTY terminal** (`portable-pty` +
|
||||
`vt100`; tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed
|
||||
to the program — runs shells / TUIs / `claude`), and the **richer script API**
|
||||
(scripts now `spawn_entity`/`despawn` and `add_component`/`set_component`/
|
||||
`remove_component` on any entity via RON through the reflection registry —
|
||||
closing the last PLAN round-trip criterion; pure-logic + headless tests → on
|
||||
`main`). **What's left in Stage 10:** the maintainer's editor-UX batch —
|
||||
Unity-style file explorer, native New/Open-Project dialog (typing the path by
|
||||
hand is too hard), a **New Script** button on the `Script` inspector,
|
||||
open-script-in-editor. See §5 to start. Parked, not blockers: editor-authored
|
||||
joint **components** need a serializable entity-reference type._
|
||||
|
||||
---
|
||||
|
||||
## 1. Where we are
|
||||
|
||||
- **Phase 1 / Stages 5–8.7: ✅ on `main`.** Engine core, editor framework,
|
||||
input + gizmos, comprehensive UI, Reflection v2 + asset database (Stage 8.5),
|
||||
and editor Play Mode (Stage 8.7).
|
||||
- **Phase 1 / Stage 9 — Physics: ✅ complete on `main`.** All pieces 1–8c done
|
||||
and promoted (eye-checked & approved 2026-06-17).
|
||||
- **Phase 1 / Stage 10 — Scripting: 🚧 core done on `main`** (crate, lifecycle,
|
||||
live reload, editor integration, Console, command terminal, interactive PTY
|
||||
terminal — all eye-checked & approved; **richer script API**
|
||||
spawn/despawn/component-edit — pure-logic, on `main`). One follow-up remains
|
||||
(the editor-UX batch — GUI, needs eye-check). Piece table + what's next in §5;
|
||||
details in `docs/scripting.md`.
|
||||
|
||||
### Stage 9 — piece status (all on `main`)
|
||||
|
||||
| # | Piece | Where |
|
||||
|---|-------|-------|
|
||||
| 1 | Component data model + module wiring: `RigidBody`/`RigidBodyKind`, `Collider`/`ColliderShape`, `PhysicsModule`, `PhysicsSettings` | `physics/` (the `oxide-physics` crate) |
|
||||
| 2 | Rapier-backed sim: build the world from components, step on `FixedUpdate`, write transforms back; by-entity forces/velocities/sleep | `physics/src/world.rs` |
|
||||
| 3 | Collision groups/masks via `LayerMask`, sensors, collision/trigger events (enter/stay/exit) | `physics/src/world.rs` |
|
||||
| 4 | Scene queries: raycast, sphere-cast, point/overlap with `LayerMask` filtering | `physics/src/world.rs` |
|
||||
| 5 | Joints/constraints: fixed, spherical, revolute, prismatic (programmatic API) | `physics/src/world.rs` |
|
||||
| 6 | Kinematic capsule character controller: move-and-slide, step offset, slope limit, grounded | `physics/src/{character,world}.rs` |
|
||||
| 7 | `examples/physics_stack` + `examples/character_capsule` (headless console demos) | `examples/` |
|
||||
| 8a | Editor integration: register RigidBody/Collider/CharacterController (addable, reflected) + enums; wire `PhysicsModule` into the play `App` (physics = first real consumer of Play; Stop reverts via snapshot) | `editor/src/state.rs`, `editor/src/main.rs` |
|
||||
| 8b | **Collider wireframe gizmos**: box/sphere/capsule/cylinder outlines over the viewport; green=solid, amber=sensor, selected drawn thicker; matches the sim (ignores `Transform::scale`); **View ▸ Show Colliders** toggle (on by default) | `editor/src/shell.rs` |
|
||||
| 8c | **Raycast debug probe**: `PhysicsWorld::sync_to_scene` (query the edited scene with no Play) + **View ▸ Raycast Probe** — click freezes a camera→cursor ray into the world (orbit to see it as a 3D line), cyan ray + magenta hit dot + normal whisker, status hint on cast. Overlay `view_proj` decoupled from selection (colliders/probe show with nothing selected) | `physics/src/world.rs`, `editor/src/{shell,main}.rs` |
|
||||
| — | Bug fix: namespace every inspector field widget per component (`ui.push_id(component_name)`) so a field name shared by two components (e.g. both `MeshRenderer` and `Collider` have `shape`) can't collide egui ids and block edits | `editor/src/shell.rs` |
|
||||
|
||||
The `oxide-physics` crate: the **ECS is the source of truth**; the rapier world
|
||||
is a transient resource rebuilt from components each step, so play-mode
|
||||
snapshot/restore works for free. Full usage + internals in `docs/physics.md`.
|
||||
|
||||
### Key design decisions (also in memory + PLAN.md)
|
||||
|
||||
- **Layers = Unity "Model A"** ([[layer-group-model-decision]]): an entity is on
|
||||
**one** `Layer` (single index, render/physics filter slot); multi-category
|
||||
needs are served by **Groups** = the multi-valued `Tags` component + a
|
||||
project-level `GroupRegistry`. **Do NOT reintroduce multi-valued `Layer`
|
||||
membership.** Physics `Collider.membership`/`filter` are `LayerMask`s.
|
||||
- **Component multiplicity = hybrid** ([[component-multiplicity-decision]]):
|
||||
one component of a type per entity (`hecs` archetypal). For "multiple things on
|
||||
one node": a **local-offset field** for naturally-single positioned things, and
|
||||
**child entities** for genuine multiples. NOT internal multi-instance lists.
|
||||
- **`AssetRef<T>` not `Handle<T>` in components**: a component stores a
|
||||
serializable `AssetRef<T>` (= `Option<AssetUid>`); `AssetRef::resolve(db,
|
||||
server)` yields the process-local `Handle<T>` at runtime. The inspector
|
||||
recognises both spellings via `asset_ref_target`.
|
||||
|
||||
---
|
||||
|
||||
## 2. How we work (follow this exactly)
|
||||
|
||||
- **Build each stage as small, independently-tested pieces.** One piece = one
|
||||
green commit. Split a piece into a pure-logic part (→ `main`) and a GUI part
|
||||
(→ `dev`) when cleaner.
|
||||
- **Local gate before every commit:**
|
||||
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
|
||||
(`#![deny(warnings)]` is on in the editor — clippy lints are hard errors, e.g.
|
||||
`too_many_arguments` fires at >7 params; bundle args into tuples/structs.)
|
||||
- **Branch promotion rule (from CLAUDE.md):**
|
||||
- Pure-logic, fully proven by automated tests → commit to `dev` **and**
|
||||
fast-forward `main`. Continue without asking. (Headless GPU pixel tests count
|
||||
as proof — run them yourself.)
|
||||
- Interactive GUI / rendering-feel / shell polish (anything a human must
|
||||
**look at**) → push to `dev` only, **ask the maintainer to run + eye-check**,
|
||||
promote after explicit approval.
|
||||
- Decision rule: "Can an automated test prove this without a human looking?"
|
||||
Yes → `main`-eligible. No → stop at `dev`.
|
||||
- **Promotion commands** (from `dev`, after committing + pushing dev):
|
||||
`git checkout main -q && git merge --ff-only dev -q && git push origin main && git checkout dev -q`
|
||||
- **Commit messages** end with: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
|
||||
- **Document as you build:** keep `PLAN.md`/`README.md`/`docs/` current in the
|
||||
**same** change. Physics docs live in `docs/physics.md`.
|
||||
|
||||
### egui gotcha
|
||||
egui's bundled font has a **limited glyph set**. Confirmed-rendering: `🗑 ➕ ⚙ ✏
|
||||
▾ … ↺`. Confirmed-tofu (avoid): `✕` (U+2715), `⧉`, `⣿` (braille). Reuse an icon
|
||||
already in the codebase or test it before shipping; prefer plain text if unsure.
|
||||
Also: egui assigns widget ids from a hash of the call path + a salt — when the
|
||||
same logical widget appears twice (two components with a same-named field), scope
|
||||
it under `ui.push_id(unique_key)` or it errors and edits silently break.
|
||||
|
||||
---
|
||||
|
||||
## 3. Conventions (match the existing code)
|
||||
|
||||
- Heavy rustdoc: each public type/fn gets a one-line summary + the "why".
|
||||
Module-level `//!` docs explain the piece's role.
|
||||
- Unit tests in `#[cfg(test)] mod tests` at the bottom of each file; integration
|
||||
tests in `tests/src/lib.rs`. Even for visual pieces, factor the math into pure
|
||||
helpers and unit-test those (e.g. `collider_wire_segments`/`push_arc` in 8b),
|
||||
so only the painting itself needs the eye-check.
|
||||
- Serialization is `serde` + RON throughout. New optional fields use
|
||||
`#[serde(default, skip_serializing_if = ...)]` so older documents still parse.
|
||||
- Errors are `thiserror` enums with specific variants.
|
||||
- Reflection: **public fields only**; `#[reflect(skip)]` drops a public field;
|
||||
`#[reflect(min=, max=)]` on an f32 → slider. Register editor-visible types in
|
||||
`oxide_editor::state::register_builtin_types`; addable ones via
|
||||
`register_addable::<T>` (needs `Default`); enums via `register_enum::<E>`.
|
||||
- New deferred ideas → `PLAN.md` backlog, not the current piece.
|
||||
|
||||
---
|
||||
|
||||
## 4. Key architecture facts
|
||||
|
||||
```rust
|
||||
// Per-field reflection on any component (zero per-type editor code):
|
||||
#[derive(Reflect, Serialize, Deserialize)]
|
||||
struct Timer { pub repeating: bool, pub duration: f32, #[reflect(skip)] pub elapsed: f32 }
|
||||
registry.register_reflected::<Timer>("Timer"); // editor + scripts
|
||||
|
||||
// Inspector renders generically: components_on -> field_infos -> get_field/set_field,
|
||||
// edits routed through SetFieldCmd (undoable, drag-coalesced).
|
||||
```
|
||||
|
||||
- **`oxide_engine::reflect`**: whole-value (`get_ron`/`set_ron`) + per-field
|
||||
(`Reflect` + `register_reflected`) layers. `register_addable` adds an "Add
|
||||
Component" constructor; `register_enum` lists variants for combo widgets.
|
||||
- **`oxide_engine::prefab`**: `Prefab { name, components }`,
|
||||
`ComponentSpec { type_name, ron }`; editor seeds built-ins (Empty/Cube/…).
|
||||
- **`oxide_engine::layer`**: single-valued `Layer { index }`; `LayerMask`
|
||||
(filter); `LayerRegistry` (names); `Tags` + `GroupRegistry` (groups).
|
||||
- **Editor inspector** (`oxide_editor::shell::ShellTabViewer`): node-baked
|
||||
section (Layer/Groups/Transform) then modular components (drag-reorder,
|
||||
enable/disable, remove, Add Component menu). `field_widget` dispatches on
|
||||
`FieldInfo.type_name`.
|
||||
- **Viewport overlay** (`editor/src/shell.rs`): the host feeds a
|
||||
`ViewportOverlay { view_proj, gizmo_size }` each frame; the Viewport tab paints
|
||||
2D over the 3D scene via `project(world, &view_proj, tab_rect) -> Pos2`.
|
||||
Transform gizmo handles, the play-state border, **and the new collider
|
||||
wireframes** all paint this way. Reuse `project` + `painter.line_segment` for
|
||||
any new world-space overlay (e.g. piece 8c's raycast viz).
|
||||
- **Play loop** (`oxide_editor::main::drive_play` + `oxide_engine::scene::
|
||||
SceneSnapshot`): owns a play `App` (DefaultModules + PhysicsModule +
|
||||
ScriptModule; **`app.assets` is set to a clone of the editor's `AssetServer`**
|
||||
and the project `AssetDatabase` is inserted as a resource so scripts resolve +
|
||||
live-reload reaches a playing scene), built on Play / dropped on Stop, **swaps
|
||||
`state.scene` in/out per tick** so the editor scene stays the single source of
|
||||
truth. Snapshot covers reflected components + `Tags`/`DisabledComponents`. Undo
|
||||
cleared on Play/Stop.
|
||||
- **Host input** (`oxide_editor::main`): viewport orbit/pan/zoom + WASD gated on
|
||||
`cursor_over_viewport && !pointer_over_floating`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exactly what to do next — finish Stage 10 (Scripting)
|
||||
|
||||
**Stage 10's core is done and on `main`** (all eye-checked & approved). The
|
||||
`oxide-script` crate is the worked-out twin of `oxide-physics` (same
|
||||
"ECS-as-truth, transient runtime resource, snapshot-for-free" shape). Full usage
|
||||
+ internals: `docs/scripting.md`. What shipped (all on `main`):
|
||||
|
||||
| Piece | Where |
|
||||
|-------|-------|
|
||||
| `oxide-script` crate: `Script`, `ScriptAsset` + `.rhai` loader (`AssetKind::Script`), sandboxed `ScriptEngine` (compile/run, op cap, `ScriptError`), `ScriptModule` | `script/` |
|
||||
| Lifecycle: `ScriptHost` + `run_scripts` (Update schedule); `init`/`update(dt)`; **engine API** (`bridge.rs`) — scripts read/write their `Transform` via a staged shared context (`position`/`translate`/`rotate_*`/`Vec3`), `rhai` `f32_float` | `script/src/{host,engine,bridge}.rs` |
|
||||
| **Live reload**: `ScriptHost` holds each script's `Handle<ScriptAsset>` alive → watcher's in-place reload recompiles (no restart); `examples/script_spin` | `script/src/host.rs`, `examples/` |
|
||||
| **Editor integration**: `Script` addable in `register_builtin_types`; `drive_play` adds `ScriptModule` + **shares the editor `AssetServer`** + a db snapshot → live `.rhai` edits update a *playing* scene | `editor/src/{state,main}.rs` |
|
||||
| **Console**: capturing logger mirrors the `log` stream into a ring buffer the panel renders (script `print` + paused-script errors via `target:"oxide_script"`) | `editor/src/console.rs`, `shell.rs::console` |
|
||||
| **Command terminal**: `$` prompt → `sh -c <cmd>` in the project root, streams stdout/stderr into the Console | `editor/src/terminal.rs` |
|
||||
| **Interactive PTY terminal**: `PanelKind::Terminal` — tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed to the program; runs shells / TUIs / `claude` | `editor/src/pty.rs`, `shell.rs::terminal_panel` |
|
||||
|
||||
### Remaining for Stage 10
|
||||
|
||||
**1. Richer script API — ✅ DONE, on `main`.** Scripts now `spawn_entity()` /
|
||||
`spawn_entity(name)`, `despawn(e)`, and `add_component`/`set_component`/
|
||||
`remove_component` on any entity, plus `entity()` for their own. Implemented as a
|
||||
**deferred command buffer** in `bridge.rs` (`EntityHandle` + `ScriptCommand`):
|
||||
the `rhai` functions can't borrow the ECS (must be `Send + Sync`), so each call
|
||||
buffers a command the host drains in `ScriptHost::apply_commands` and applies via
|
||||
the reflection registry (`app.types`, `set_ron`/`add_default`/`remove`). `spawn_entity`
|
||||
returns a **provisional** handle resolvable in the same frame (`spawn` is a rhai
|
||||
reserved word, hence the longer name). Component edits go through RON, closing the
|
||||
last PLAN round-trip criterion. Headless tests in `host.rs`/`bridge.rs`; docs in
|
||||
`docs/scripting.md` ("Spawning entities and editing components").
|
||||
|
||||
**2. Maintainer's editor-UX batch (GUI → `dev` + eye-check).** Details in
|
||||
PLAN.md "Editor-UX follow-ups" + [[editor-file-explorer-preference]]. The
|
||||
maintainer asked for these after the terminal work:
|
||||
- **"New Script" button** on the `Script` inspector — write a `.rhai` template
|
||||
into `assets/scripts/`, register it in the db, auto-assign it to the component.
|
||||
There is **no in-editor script creation today** (scripts are authored as
|
||||
files). Good small first one to pull forward.
|
||||
- **Open a script in an editor** — double-click / button to open a `.rhai` in an
|
||||
in-editor text view or launch `$EDITOR` / a configured external editor; edits
|
||||
flow back through live reload (you can also just run `$EDITOR file` in the new
|
||||
PTY terminal).
|
||||
- **Unity-style file explorer** in the Project panel (create/rename/move folders,
|
||||
drag-in OS import, context menus) over the existing `AssetDatabase`.
|
||||
- **Native New/Open-Project dialog** (`rfd`, must work on Wayland + X11) —
|
||||
typing the project path by hand "is very hard to use".
|
||||
|
||||
**Reuse, don't reinvent:** `oxide-physics`/`oxide-script` are the template for
|
||||
"new crate → `Module` + schedule → reflected/addable components → editor
|
||||
registration → play-loop integration". The play loop
|
||||
(`oxide_editor::main::drive_play`) runs scripts alongside physics and shares the
|
||||
editor's asset server, so live reload reaches a playing scene.
|
||||
|
||||
**Terminal design note:** the embedded terminal is a general PTY widget
|
||||
(`portable-pty` + `vt100`); run any program (incl. `claude`) from a *Shell* tab.
|
||||
A "one-app launcher" was considered and rejected — it needs the same
|
||||
PTY+VT+render+input stack, so the general widget is strictly better.
|
||||
|
||||
**Don't do these unless asked (parked in PLAN.md):** editor authoring of joint
|
||||
**components** (needs a serializable entity-reference type); standalone game
|
||||
"Launch" (deferred to Stage 16).
|
||||
|
||||
---
|
||||
|
||||
## 6. Quick orientation commands
|
||||
|
||||
```sh
|
||||
cargo test # full suite (all green at handoff)
|
||||
cargo test -p oxide-script # the scripting crate (lifecycle, live reload)
|
||||
cargo test -p oxide-editor --lib pty # PTY terminal pure helpers
|
||||
cargo run -p oxide-examples --bin script_spin # headless live-reload demo
|
||||
cargo run -p oxide-editor # the editor (Console + Terminal panels)
|
||||
git log --oneline -15 # recent per-piece commits
|
||||
sed -n '/## Stage 10/,/## Stage 11/p' PLAN.md # Stage 10 spec + status
|
||||
```
|
||||
|
||||
Memory files (auto-loaded each session) track decisions: `MEMORY.md` index →
|
||||
`layer-group-model-decision.md` → `component-multiplicity-decision.md` →
|
||||
`run-on-target-pc.md` (headless-vs-eye-check rule) →
|
||||
`workflow-auto-continue.md` (don't pause between pure-logic pieces).
|
||||
@@ -13,7 +13,7 @@ stylized low-poly to realistic graphics, and shipping only what each game uses.
|
||||
|
||||
## Status
|
||||
|
||||
Oxide is built in two phases (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap)): **Phase 1 (Stages 0–16)**
|
||||
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 0–16)**
|
||||
is the general-purpose engine — everything needed to build and export any game;
|
||||
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
|
||||
|
||||
@@ -74,7 +74,7 @@ Available now:
|
||||
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
|
||||
piece 7)
|
||||
|
||||
## Features (planned — see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap))
|
||||
## Features (planned — see [PLAN.md](PLAN.md))
|
||||
|
||||
**Phase 1 — the general-purpose engine:**
|
||||
|
||||
@@ -117,7 +117,7 @@ Available now:
|
||||
|
||||
- Rust stable toolchain (`rustup` recommended)
|
||||
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
|
||||
game export are planned (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap), Stage 16); other platforms are not yet tested.
|
||||
game export are planned (see [PLAN.md](PLAN.md), Stage 16); other platforms are not yet tested.
|
||||
- A GPU with Vulkan or Metal support (for `wgpu`)
|
||||
|
||||
Install Rust if you don't have it:
|
||||
@@ -208,23 +208,18 @@ cargo fmt --check
|
||||
## Documentation
|
||||
|
||||
The full documentation — usage guides, per-system API references, and
|
||||
explanations of the engine's inner workings — lives on the
|
||||
[wiki](https://git.houmeres.sk/Houmeres/Oxide/wiki). Start there for anything
|
||||
beyond this overview:
|
||||
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md).
|
||||
Start there for anything beyond this overview:
|
||||
|
||||
- [Getting Started](https://git.houmeres.sk/Houmeres/Oxide/wiki/Getting-started) — build, run, test, install
|
||||
- [Architecture](https://git.houmeres.sk/Houmeres/Oxide/wiki/Architecture) — workspace and design overview
|
||||
- [Conventions](https://git.houmeres.sk/Houmeres/Oxide/wiki/Conventions) — coordinate system, units, color space
|
||||
- [Development Workflow](https://git.houmeres.sk/Houmeres/Oxide/wiki/Development) — branches, testing, contributing
|
||||
- [Math & Core Primitives](https://git.houmeres.sk/Houmeres/Oxide/wiki/Math) — Stage 1 API reference
|
||||
- [Windowing & App Loop](https://git.houmeres.sk/Houmeres/Oxide/wiki/Windowing) — Stage 2 window/event-loop reference
|
||||
- [Render Context](https://git.houmeres.sk/Houmeres/Oxide/wiki/Render-context) — Stage 2 GPU/surface reference
|
||||
- [Scene Graph & Entities](https://git.houmeres.sk/Houmeres/Oxide/wiki/Scene) — Stage 3 scene/hierarchy/serialization reference
|
||||
- [Rendering](https://git.houmeres.sk/Houmeres/Oxide/wiki/Rendering) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||
|
||||
The [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap) is the staged
|
||||
plan, and [Working notes](https://git.houmeres.sk/Houmeres/Oxide/wiki/Working-notes)
|
||||
holds the project's rules. Both used to be files in this repository.
|
||||
- [Getting Started](docs/getting-started.md) — build, run, test, install
|
||||
- [Architecture](docs/architecture.md) — workspace and design overview
|
||||
- [Conventions](docs/conventions.md) — coordinate system, units, color space
|
||||
- [Development Workflow](docs/development.md) — branches, testing, contributing
|
||||
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference
|
||||
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference
|
||||
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference
|
||||
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference
|
||||
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||
|
||||
---
|
||||
|
||||
@@ -233,11 +228,14 @@ holds the project's rules. Both used to be files in this repository.
|
||||
```
|
||||
Oxide/
|
||||
├── assets/ # Project logos and shared assets
|
||||
├── docs/ # Full engine documentation
|
||||
├── engine/ # Core engine library (oxide-engine)
|
||||
├── editor/ # In-engine editor binary (oxide-editor)
|
||||
├── examples/ # Runnable stage examples (oxide-examples)
|
||||
├── tests/ # Integration test harness (oxide-tests)
|
||||
└── install.sh # Build + system install script
|
||||
├── install.sh # Build + system install script
|
||||
├── PLAN.md # Staged development roadmap
|
||||
└── CLAUDE.md # Context and rules for Claude Code
|
||||
```
|
||||
|
||||
---
|
||||
@@ -245,7 +243,7 @@ Oxide/
|
||||
## Development roadmap
|
||||
|
||||
Development follows a staged plan — each stage is fully tested before the next begins.
|
||||
See the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap) for the complete plan.
|
||||
See [PLAN.md](PLAN.md) for the complete roadmap.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Oxide Engine Documentation
|
||||
|
||||
This directory is the canonical, in-depth documentation for the Oxide engine. The
|
||||
top-level [`README.md`](../README.md) is a short overview; everything detailed —
|
||||
usage guides, API references, and explanations of how the engine works
|
||||
internally — lives here and grows with the engine at every stage.
|
||||
|
||||
## How this documentation is organized
|
||||
|
||||
| Document | What it covers |
|
||||
|----------|----------------|
|
||||
| [getting-started.md](getting-started.md) | Installing the toolchain, building, running examples, running tests and benchmarks |
|
||||
| [architecture.md](architecture.md) | Workspace layout, crate responsibilities, design philosophy, staged development model |
|
||||
| [conventions.md](conventions.md) | Coordinate system, handedness, units, color space, and other cross-cutting conventions |
|
||||
| [development.md](development.md) | Branch workflow (`dev`/`main`), testing protocol, documentation policy, how to add a stage |
|
||||
| [math.md](math.md) | Full reference for the `oxide_engine::math` module (Stage 1) |
|
||||
| [windowing.md](windowing.md) | Window creation, the `App` trait and event loop, raw input events (Stage 2) |
|
||||
| [render-context.md](render-context.md) | GPU acquisition, surface configuration, the clear-color frame loop (Stage 2) |
|
||||
| [scene.md](scene.md) | Scene graph, entities, the transform hierarchy, and serialization (Stage 3) |
|
||||
| [rendering.md](rendering.md) | Meshes, materials, camera, and the forward renderer (Stage 4) |
|
||||
| [layers.md](layers.md) | Layers, groups & tags: single-valued `Layer` + multi-valued `Tags`/`GroupRegistry`, the shared `LayerMask` filter primitive (Stage 5) |
|
||||
| [reflection.md](reflection.md) | Reflection / type registry: generic name-keyed component access for dual-editability (Stage 5) |
|
||||
| [prefabs.md](prefabs.md) | Prefabs: data-driven named spawn templates (component specs applied via the registry) (Stage 8.5) |
|
||||
| [assets.md](assets.md) | Asset server & handles: ref-counted loading, dedup, loaders, background load, reload (Stage 5) |
|
||||
| [modules.md](modules.md) | App, modules & scheduling: composing the engine, system phases, fixed timestep, enable/disable/remove (Stage 5) |
|
||||
| [render-pipeline.md](render-pipeline.md) | Data-driven render pass pipeline: composable passes, scalable fidelity, camera layer visibility (Stage 5) |
|
||||
| [projects.md](projects.md) | Project system: project file, folder layout, create/open/save, recent projects (Stage 6) |
|
||||
| [settings.md](settings.md) | Settings & preferences framework: typed sections, export/import, per-module/per-project settings (Stage 6) |
|
||||
| [file-watching.md](file-watching.md) | File-watcher foundation: debounced/deduplicated change events; asset live-reload wiring (Stage 6) |
|
||||
| [editor-extensions.md](editor-extensions.md) | Module → editor extension API: how a module contributes menus, panels, tools, inspectors, settings pages (Stage 6) |
|
||||
| [editor-shell.md](editor-shell.md) | Editor docking shell: menu bar, dockable panels, status bar, Preferences window, command stack + file watcher wiring (Stage 6) |
|
||||
| [input.md](input.md) | Input system: per-frame `InputState` with edge detection, named action mapping (`ActionMap`/`Binding`) with defaults + remapping + RON persistence, 1D/2D directional axes (Stage 7) |
|
||||
| [ui.md](ui.md) | In-game UI: widget tree, layout (stack/grid/anchor), DPI-aware sizing, per-widget visual styles + named-style themes, RON dual-edit, ab_glyph-backed text shaping + R8 glyph atlas, screen-space + world-space `UiOverlayPass`, hit-test + hover/focus/press `Router`, immediate-mode event queries + typed `WidgetValue`s (Stage 8 — pieces 1–6, GUI tail coming) |
|
||||
| [play-mode.md](play-mode.md) | Editor play mode: `PlayState`, registry-aware `SceneSnapshot` snapshot/restore, the `tick_for` decision + `App::step`, the scene-swap runner, toolbar/shortcuts/viewport tint (Stage 8.7) |
|
||||
| [physics.md](physics.md) | Physics module (`oxide-physics`, rapier3d): `RigidBody`/`Collider` components, `LayerMask`-filtered collision, the ECS-as-source-of-truth model, module wiring (Stage 9) |
|
||||
| [scripting.md](scripting.md) | Scripting module (`oxide-script`, rhai): the `Script` component, `ScriptAsset` + `.rhai` loader, the sandboxed `ScriptEngine` wrapper, module wiring; ECS-as-source-of-truth + live-reload model (Stage 10) |
|
||||
|
||||
## Documentation status by stage
|
||||
|
||||
Documentation is written alongside the code. A stage is not considered done until
|
||||
its docs exist here.
|
||||
|
||||
| Stage | Subject | Docs |
|
||||
|-------|---------|------|
|
||||
| 0 | Project foundation | [architecture.md](architecture.md), [getting-started.md](getting-started.md) |
|
||||
| 1 | Math & core primitives | [math.md](math.md) |
|
||||
| 2 | Window & render context | [windowing.md](windowing.md), [render-context.md](render-context.md) |
|
||||
| 3 | Scene graph & entity system | [scene.md](scene.md) |
|
||||
| 4 | Basic 3D rendering | [rendering.md](rendering.md) |
|
||||
| 5 | Engine core framework | [modules.md](modules.md), [layers.md](layers.md), [reflection.md](reflection.md), [assets.md](assets.md), [render-pipeline.md](render-pipeline.md) |
|
||||
| 6 | Editor framework & project system | [projects.md](projects.md), [settings.md](settings.md), [file-watching.md](file-watching.md), [editor-extensions.md](editor-extensions.md), [editor-shell.md](editor-shell.md) |
|
||||
| 7 | Input system | [input.md](input.md) |
|
||||
| 8 | Comprehensive UI system (in progress) | [ui.md](ui.md) |
|
||||
| 8.7 | Editor play mode | [play-mode.md](play-mode.md) |
|
||||
| 9 | Physics integration (in progress) | [physics.md](physics.md) |
|
||||
| 10 | Scripting, live reload & editor terminal (in progress) | [scripting.md](scripting.md) |
|
||||
| 11+ | — | _added as each stage lands_ |
|
||||
|
||||
## Conventions for these files
|
||||
|
||||
- One topic per file; keep files focused and link between them rather than
|
||||
duplicating content.
|
||||
- Every public type or system gets: a one-line summary, when to use it, and at
|
||||
least one runnable code snippet.
|
||||
- Code snippets are written so they would compile against the current API. When
|
||||
the API changes, update the snippet in the same change.
|
||||
- Prefer explaining the *why* (design intent, trade-offs) over restating the
|
||||
*what* (which the rustdoc comments already cover).
|
||||
@@ -0,0 +1,157 @@
|
||||
# Architecture
|
||||
|
||||
This document explains how Oxide is structured and the principles that govern how
|
||||
it is built. For per-system detail, see the topic documents (e.g. [math.md](math.md)).
|
||||
|
||||
## Goals
|
||||
|
||||
Oxide is a general-purpose 3D game engine written in Rust, built to make **any**
|
||||
3D game — scaling from stylized low-poly to realistic graphics — and to **ship
|
||||
only what each game uses**. It is built in two phases: a **general-purpose engine**
|
||||
(Phase 1, scene graph, render pass pipeline, input, UI, physics, scripting,
|
||||
animation, particles, shaders, audio, content kit, and game export) and a set of
|
||||
optional, feature-gated **built-in modules** (Phase 2: ray-traced sound, developer
|
||||
console, procedural toolkit, terrain, open world, pathfinding/AI, water). The
|
||||
guiding idea is **build tools, not games**, all driven through a first-class
|
||||
**in-engine editor**. See [`PLAN.md`](../PLAN.md) for the staged roadmap.
|
||||
|
||||
## Design philosophy
|
||||
|
||||
These principles are non-negotiable and shape every decision:
|
||||
|
||||
1. **Build in stages.** Each stage produces a usable, standalone artifact and
|
||||
must be fully tested and stable before the next begins. See
|
||||
[development.md](development.md) and [`PLAN.md`](../PLAN.md).
|
||||
2. **Composability.** Every system should be usable independently of the others.
|
||||
You should be able to pull in the math module, or the scene graph, without
|
||||
dragging in the renderer.
|
||||
3. **Correctness and clarity over premature optimization.** Optimize when a
|
||||
benchmark says to, not before.
|
||||
4. **Minimal public surface.** Internal complexity is fine; the external API
|
||||
should be small and clean. Modules expose a curated set of types through
|
||||
`pub use`, not their whole internal structure.
|
||||
5. **No feature creep between stages.** New ideas go to the backlog, not into the
|
||||
current stage.
|
||||
6. **The editor grows with the engine.** `oxide-editor` is a first-class
|
||||
deliverable, gaining panels and tools as each stage adds systems.
|
||||
7. **Build tools, not games.** Ship composable building blocks; genre-specific
|
||||
behavior belongs in game code or optional modules.
|
||||
8. **Ship only what's used.** Subsystems are feature-gated **modules** (from
|
||||
Stage 5); an exported game compiles in only the modules it registers.
|
||||
9. **Scalable fidelity.** A data-driven render pass pipeline lets a project run
|
||||
anything from a flat low-poly/stylized look to a full realistic stack, paying
|
||||
only for the passes it enables.
|
||||
10. **Modules are the primary extension point.** A module registers engine logic
|
||||
*and* editor UI *and* its own settings through one documented API; anyone —
|
||||
including AI agents — can write one.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
Oxide is a single Cargo workspace. Crates share version, edition, license, and
|
||||
dependency versions through `[workspace.package]` and `[workspace.dependencies]`
|
||||
in the root `Cargo.toml`.
|
||||
|
||||
```
|
||||
Oxide/
|
||||
├── engine/ # oxide-engine — the core library (all engine systems)
|
||||
├── editor/ # oxide-editor — the in-engine editor binary
|
||||
├── examples/ # oxide-examples — runnable examples, one+ per stage
|
||||
├── tests/ # oxide-tests — integration / end-to-end test harness
|
||||
├── docs/ # this documentation
|
||||
├── assets/ # logos and shared assets
|
||||
├── install.sh # release build + system install
|
||||
├── PLAN.md # authoritative staged roadmap
|
||||
├── README.md # short project overview
|
||||
└── CLAUDE.md # rules and context for AI-assisted development
|
||||
```
|
||||
|
||||
### Crate responsibilities
|
||||
|
||||
- **`oxide-engine`** — the library that contains every engine system. It is
|
||||
organized as one module per system (`math`, and later `scene`, `render`,
|
||||
`physics`, …). Each module is independently usable and re-exports its public
|
||||
types. A `prelude` module collects the most common imports.
|
||||
- **`oxide-editor`** — the binary users run. It depends on `oxide-engine` and
|
||||
builds a UI on top of engine systems (its own window with a placeholder
|
||||
viewport since Stage 2; egui panels from Stage 3). It never contains engine
|
||||
logic itself; it is a consumer of the engine.
|
||||
- **`oxide-examples`** — small, focused programs that each demonstrate one
|
||||
stage's capabilities. They double as manual-review artifacts and as living
|
||||
documentation. `publish = false`; they are never installed.
|
||||
- **`oxide-tests`** — integration tests that exercise the engine the way a real
|
||||
consumer would, including cross-module scenarios and fuzz/property tests.
|
||||
|
||||
## Engine module structure
|
||||
|
||||
Inside `oxide-engine`, each system is a module under `engine/src/`. The pattern,
|
||||
established by the math module, is:
|
||||
|
||||
```
|
||||
engine/src/
|
||||
├── lib.rs # declares modules, defines the prelude
|
||||
├── math/
|
||||
│ ├── mod.rs # module docs + curated `pub use` re-exports
|
||||
│ ├── transform.rs # one type/concept per file, with its own tests
|
||||
│ ├── aabb.rs
|
||||
│ └── ...
|
||||
├── render/ # Stage 2: GPU acquisition + surface clear loop
|
||||
│ ├── mod.rs # RenderError, clear_view, re-exports
|
||||
│ ├── gpu.rs # Gpu (instance/adapter/device/queue)
|
||||
│ └── context.rs # RenderContext (surface, resize, render_frame)
|
||||
└── window/ # Stage 2: window + event loop + App trait
|
||||
├── mod.rs # WindowConfig, `event` re-export module
|
||||
├── app.rs # App trait, AppCtx
|
||||
└── runner.rs # winit ApplicationHandler internals
|
||||
```
|
||||
|
||||
Rules of thumb:
|
||||
|
||||
- **One concept per file.** Prefer many small focused files over a few large
|
||||
ones.
|
||||
- **Tests live with the code.** Each file has a `#[cfg(test)] mod tests` block
|
||||
covering core behavior and edge cases.
|
||||
- **The module root curates the API.** `mod.rs` decides what is public via
|
||||
`pub use`; submodules are private (`mod foo;`, not `pub mod foo;`) unless there
|
||||
is a reason to expose the path.
|
||||
- **The prelude is the front door.** `oxide_engine::prelude::*` brings in the
|
||||
types a typical consumer needs, including re-exported third-party math types so
|
||||
downstream code needs only one dependency for everyday work.
|
||||
|
||||
## Key dependencies
|
||||
|
||||
Chosen for portability and a lightweight footprint:
|
||||
|
||||
| Concern | Crate | Notes |
|
||||
|---------|-------|-------|
|
||||
| Math | [`glam`](https://docs.rs/glam) | SIMD-friendly vectors/quats/matrices; `serde` feature enabled |
|
||||
| Graphics | [`wgpu`](https://docs.rs/wgpu) | Portable across Vulkan/Metal/DX12 (since Stage 2; re-exported as `oxide_engine::wgpu`) |
|
||||
| Windowing | [`winit`](https://docs.rs/winit) | Cross-platform windows and events (since Stage 2; re-exported as `oxide_engine::winit`) |
|
||||
| ECS | [`hecs`](https://docs.rs/hecs) | Lightweight archetypal ECS (Stage 3+) |
|
||||
| Physics | [`rapier3d`](https://docs.rs/rapier3d) | Rigid bodies and collision (Stage 6+) |
|
||||
| Editor UI | [`egui`](https://docs.rs/egui) | Immediate-mode UI (Stage 3+) |
|
||||
| Logging | `log` + `env_logger` | Facade + env-driven backend |
|
||||
| Errors | `anyhow` + `thiserror` | Application vs. library error handling |
|
||||
| Serialization | `serde` + `ron` | Scene/asset (de)serialization (Stage 3+) |
|
||||
|
||||
## Error handling and logging
|
||||
|
||||
- **Libraries (`oxide-engine`)** define their own error types with `thiserror`
|
||||
so callers can match on failure modes.
|
||||
- **Binaries (`oxide-editor`, examples)** use `anyhow` for ergonomic error
|
||||
propagation at the top level.
|
||||
- **Logging** uses the `log` facade throughout the engine; binaries initialize a
|
||||
backend (`env_logger`). Control verbosity with `RUST_LOG`, e.g.
|
||||
`RUST_LOG=oxide_engine=debug cargo run -p oxide-examples --bin math_demo`.
|
||||
|
||||
## Installability
|
||||
|
||||
Oxide must remain installable as a Linux package at all times. `install.sh`
|
||||
builds in release mode and installs the `oxide-editor` binary and assets under a
|
||||
prefix (`/usr/local` by default, overridable with `PREFIX`). Any new installed
|
||||
binary or asset must be reflected in `install.sh` in the same change.
|
||||
|
||||
## See also
|
||||
|
||||
- [conventions.md](conventions.md) — coordinate system, units, color space
|
||||
- [development.md](development.md) — workflow, testing, and how stages progress
|
||||
- [`PLAN.md`](../PLAN.md) — the full staged roadmap
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# Asset Server & Handles
|
||||
|
||||
`oxide_engine::asset` is the engine's central way to load and own external data
|
||||
(meshes, and later textures, audio, fonts, …). It lands in Stage 5 and underpins
|
||||
three later stages: live reload (Stage 10), open-world streaming (Stage 21), and
|
||||
export packing (Stage 16). Putting it in early means later loaders plug into one
|
||||
system instead of each inventing its own.
|
||||
|
||||
## Handles own assets
|
||||
|
||||
The unit of ownership is a [`Handle<T>`]: a typed, reference-counted reference to
|
||||
a loaded asset. It is cheap to clone (an `Arc` bump), and the asset behind it
|
||||
lives exactly as long as at least one handle does. The [`AssetServer`] keeps only
|
||||
a `Weak` reference in its dedup cache, so it never keeps an otherwise-unused
|
||||
asset alive — drop the last handle and the asset is freed.
|
||||
|
||||
```rust
|
||||
use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||
|
||||
let assets = AssetServer::new(); // built-in loaders (glTF) registered
|
||||
let model: Handle<GltfModel> = assets.load("assets/models/cube.gltf");
|
||||
|
||||
if let Some(model) = model.get() { // Option<Arc<GltfModel>>, None until loaded
|
||||
println!("{} meshes", model.meshes.len());
|
||||
}
|
||||
```
|
||||
|
||||
Key handle methods:
|
||||
|
||||
| Method | Meaning |
|
||||
|--------|---------|
|
||||
| `get()` | `Some(Arc<T>)` once loaded, else `None` (cheap clone of the value `Arc`) |
|
||||
| `state()` / `is_loaded()` | lifecycle: `Loading` / `Loaded` / `Failed` |
|
||||
| `wait()` | block until ready; `Some(value)` or `None` on failure |
|
||||
| `error()` | the failure message, if any |
|
||||
| `source()` | the path it was loaded from (in-memory assets have none) |
|
||||
| `ref_count()` | number of live handles (the server holds none) |
|
||||
|
||||
## Deduplication and freeing
|
||||
|
||||
Loading the same path+type twice returns handles to **one** shared asset — the
|
||||
loader runs once:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||
# let assets = AssetServer::new();
|
||||
let a: Handle<GltfModel> = assets.load("model.gltf");
|
||||
let b: Handle<GltfModel> = assets.load("model.gltf");
|
||||
assert_eq!(a.id(), b.id()); // same underlying asset
|
||||
assert_eq!(assets.live_asset_count(), 1);
|
||||
```
|
||||
|
||||
When the last handle drops, the weak cache entry dies and the asset is collected;
|
||||
`live_asset_count()` prunes such entries as it counts.
|
||||
|
||||
## Synchronous vs background loading
|
||||
|
||||
`load` blocks until the asset is ready. `load_async` returns immediately with a
|
||||
handle in the `Loading` state and fills it on a background thread:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||
# let assets = AssetServer::new();
|
||||
let handle: Handle<GltfModel> = assets.load_async("big.gltf");
|
||||
// ... do other work while it loads ...
|
||||
let model = handle.wait(); // or poll handle.state()
|
||||
```
|
||||
|
||||
`add(value)` stores an already-constructed, in-memory asset (no path, not
|
||||
deduplicated) and returns a handle to it — useful for procedurally generated or
|
||||
test data.
|
||||
|
||||
## Writing a loader
|
||||
|
||||
Formats are pluggable via the [`AssetLoader`] trait: declare the output type and
|
||||
the extensions, and implement `load`. Register it with
|
||||
`server.register_loader(...)`; the server dispatches by extension and verifies
|
||||
the loader's output type matches the requested `T`.
|
||||
|
||||
```rust
|
||||
use std::path::Path;
|
||||
use oxide_engine::asset::{AssetError, AssetLoader};
|
||||
|
||||
struct TextLoader;
|
||||
impl AssetLoader for TextLoader {
|
||||
type Asset = String;
|
||||
fn extensions(&self) -> &'static [&'static str] { &["txt"] }
|
||||
fn load(&self, path: &Path) -> Result<String, AssetError> {
|
||||
std::fs::read_to_string(path).map_err(|e| AssetError::Load {
|
||||
path: path.to_path_buf(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let assets = oxide_engine::asset::AssetServer::empty(); // no built-ins
|
||||
assets.register_loader(TextLoader);
|
||||
```
|
||||
|
||||
The built-in [`GltfLoader`] is exactly this pattern wrapping the Stage-4
|
||||
[`load_gltf`] importer; `AssetServer::new()` registers it for `.gltf`/`.glb`.
|
||||
Errors are specific — `NoExtension`, `NoLoader`, `TypeMismatch`, and `Load` — so
|
||||
callers can tell "no loader" from "the file is broken".
|
||||
|
||||
## Live reload foundation
|
||||
|
||||
`reload(path)` re-runs the loader and updates the existing asset **in place**, so
|
||||
every live handle observes the new contents on its next `get()`:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
|
||||
# let assets = AssetServer::new();
|
||||
let model: Handle<GltfModel> = assets.load("model.gltf");
|
||||
// ... the file changes on disk ...
|
||||
let same = assets.reload::<GltfModel>("model.gltf");
|
||||
assert_eq!(same.id(), model.id()); // same asset, new data
|
||||
```
|
||||
|
||||
Stage 10 wires this to the file watcher to hot-reload changed assets while the
|
||||
editor runs.
|
||||
|
||||
## Asset database — stable, project-relative references
|
||||
|
||||
The [`AssetServer`] loads by *path*, but a scene or UI document must not bake an
|
||||
**absolute** system path into its saved data — that breaks the moment the
|
||||
project moves to another directory or machine, and it is the chief obstacle to a
|
||||
clean game export (Stage 16). The [`AssetDatabase`] is the bridge.
|
||||
|
||||
Every imported asset lives under a **typed subfolder** of the project's
|
||||
`assets/` directory and gets a stable [`AssetUid`]. The database records, per
|
||||
project, the mapping **`AssetUid` ↔ assets-relative path** (e.g.
|
||||
`"fonts/Inter-Regular.ttf"`) in a manifest at the project root
|
||||
(`assets.manifest`). Saved documents reference assets by `AssetUid`; resolving a
|
||||
uid yields the relative path, which combined with the *current* project root
|
||||
gives an absolute path the server loads and deduplicates.
|
||||
|
||||
```rust
|
||||
use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel};
|
||||
|
||||
let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present
|
||||
db.scan(); // discover files in fonts/ models/ …
|
||||
db.save().unwrap(); // persist any newly-assigned uids
|
||||
|
||||
let uid = db.uid_of("models/cube.glb").unwrap();
|
||||
let server = AssetServer::new();
|
||||
let model = db.load::<GltfModel>(&server, uid); // Option<Handle<GltfModel>>
|
||||
```
|
||||
|
||||
Because the stored mapping is purely relative, a reference resolves to the **same
|
||||
handle** across save/load *and* after the whole project directory moves — open
|
||||
the database from the new location (or call `set_root`) and every uid keeps
|
||||
resolving. The uid layer (rather than referencing by relative path directly)
|
||||
also lets an asset be renamed or moved *within* the project later without
|
||||
breaking references, since the uid travels with the file in the manifest.
|
||||
|
||||
### Typed folders
|
||||
|
||||
[`AssetKind`] fixes each asset's subfolder and the extensions that belong to it:
|
||||
|
||||
| Kind | Folder | Extensions |
|
||||
|------|--------|-----------|
|
||||
| `Font` | `fonts/` | `ttf`, `otf` |
|
||||
| `Texture` | `textures/` | `png`, `jpg`, `jpeg`, `tga`, `bmp`, `dds`, `ktx2` |
|
||||
| `Model` | `models/` | `gltf`, `glb`, `obj` |
|
||||
| `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` |
|
||||
| `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) |
|
||||
|
||||
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
|
||||
file extension as a fallback for files dropped directly in `assets/`.
|
||||
|
||||
### Asset-reference fields in the inspector
|
||||
|
||||
A component points at an asset with an [`AssetRef<T>`] field — **not** a live
|
||||
`Handle<T>`. A handle is process-local and not serializable, so persisting one
|
||||
would be wrong; an `AssetRef<T>` is a thin, serializable wrapper over
|
||||
`Option<AssetUid>` that resolves to a handle on demand:
|
||||
|
||||
```rust
|
||||
use oxide_engine::asset::{AssetRef, AssetServer, AssetDatabase};
|
||||
use oxide_engine::ui::Font;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct Label { font: AssetRef<Font> } // serializes as just the uid
|
||||
|
||||
# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) {
|
||||
let handle = label.font.resolve(db, server); // Option<Handle<Font>>
|
||||
# }
|
||||
```
|
||||
|
||||
Because `AssetRef<T>` round-trips through reflection's RON path, the field is
|
||||
editable in the inspector with **no per-type code**. Its
|
||||
[`type_name`](../engine/src/reflect.rs) is the syntactic spelling
|
||||
`"AssetRef < Font >"`; [`asset_ref_target`] unwraps that to the target type name
|
||||
(`"Font"`), and [`AssetKind::for_handle_target`] maps it to the kind the editor's
|
||||
asset picker filters by — so selecting a UI element and picking a font lists only
|
||||
the assets under `fonts/`. A bare `Handle<T>` field (e.g. on a non-persisted
|
||||
type) is recognised the same way.
|
||||
|
||||
### In the editor
|
||||
|
||||
`EditorState` holds an `asset_db` whenever a project is open: the editor opens
|
||||
and scans it on project open/create and rescans when the file watcher reports
|
||||
changes under `assets/`, so importing an asset is just **dropping the file into
|
||||
the matching typed folder** (`fonts/`, `textures/`, …) — no separate import
|
||||
step. The **Project panel** is an asset browser listing each typed folder's
|
||||
assets from the database, and an asset-reference field in the inspector renders
|
||||
as a **picker** populated from it, filtered to the field's target kind. New
|
||||
projects are seeded with a bundled default UI font (Inter, SIL OFL) under
|
||||
`fonts/`, referenced by its project-relative path like any other asset.
|
||||
|
||||
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
||||
[`AssetKind`]: ../engine/src/asset/database.rs
|
||||
[`AssetUid`]: ../engine/src/asset/database.rs
|
||||
[`AssetRef<T>`]: ../engine/src/asset/database.rs
|
||||
[`asset_ref_target`]: ../engine/src/asset/database.rs
|
||||
[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs
|
||||
[`Handle<T>`]: ../engine/src/asset/handle.rs
|
||||
[`AssetServer`]: ../engine/src/asset/server.rs
|
||||
[`AssetLoader`]: ../engine/src/asset/server.rs
|
||||
[`GltfLoader`]: ../engine/src/asset/gltf.rs
|
||||
[`load_gltf`]: ../engine/src/asset/gltf.rs
|
||||
@@ -0,0 +1,90 @@
|
||||
# Conventions
|
||||
|
||||
Cross-cutting conventions every Oxide system follows. These are decided once,
|
||||
here, so individual systems don't each invent their own.
|
||||
|
||||
## Coordinate system
|
||||
|
||||
Oxide uses a **right-handed** coordinate system, consistent with `glam`'s
|
||||
`*_rh` matrix constructors and the glTF asset format the engine will load.
|
||||
|
||||
In a default (identity) orientation:
|
||||
|
||||
| Axis | Direction | Local accessor on `Transform` |
|
||||
|------|-----------|-------------------------------|
|
||||
| `+X` | right | `Transform::right()` |
|
||||
| `+Y` | up | `Transform::up()` |
|
||||
| `-Z` | forward (the direction a camera/object looks) | `Transform::forward()` |
|
||||
|
||||
So **forward is `-Z`**. This matches the convention used by glTF, OpenGL, and
|
||||
`glam`'s view-matrix helpers, which keeps asset import and camera math
|
||||
consistent.
|
||||
|
||||
`Transform::looking_at(eye, target, up)` produces an orientation whose
|
||||
`forward()` points from `eye` toward `target`.
|
||||
|
||||
## Rotations
|
||||
|
||||
- Rotations are stored as **unit quaternions** (`glam::Quat`), not Euler angles
|
||||
or matrices, to avoid gimbal lock and accumulate cleanly under composition.
|
||||
- Euler-angle helpers (`Quat::from_euler`) are available for authoring, but the
|
||||
canonical stored form is always a quaternion.
|
||||
- Angles are in **radians**. Convert from degrees explicitly at the boundary
|
||||
(`90_f32.to_radians()`).
|
||||
|
||||
## Transform composition
|
||||
|
||||
- Transforms compose **parent-first**: `parent.mul_transform(&child)` yields the
|
||||
child resolved in the parent's space, matching `parent_matrix * child_matrix`.
|
||||
- The effective matrix order is `T * R * S` — scale is applied first, then
|
||||
rotation, then translation.
|
||||
- Composition is **exact** for uniform scale. With non-uniform scale plus
|
||||
rotation the true product is not representable as a single translation /
|
||||
rotation / scale triple, so the result is the closest TRS approximation
|
||||
(re-decomposed from the matrix). Prefer uniform scale in deep hierarchies.
|
||||
|
||||
See [math.md](math.md#transform) for details.
|
||||
|
||||
## Units
|
||||
|
||||
- **Length:** meters. Physics (`rapier3d`, Stage 6) tunes its solver for
|
||||
meter-scale geometry, so the whole engine adopts meters to avoid conversions.
|
||||
- **Time:** seconds (`f32` for per-frame deltas; a fixed timestep drives physics
|
||||
from Stage 6).
|
||||
- **Angles:** radians (see above).
|
||||
- **Mass:** kilograms (Stage 6+).
|
||||
|
||||
## Numeric type
|
||||
|
||||
- The engine is **`f32`-first**. `glam`'s `f32` types are the default throughout;
|
||||
`f64` is used only where a specific algorithm demands it.
|
||||
- Comparisons use explicit epsilons rather than `==` on floats. Helpers and tests
|
||||
use a small tolerance (commonly `1e-4`–`1e-6`) appropriate to the operation.
|
||||
|
||||
## Color and color space
|
||||
|
||||
- `Color` stores **linear** RGBA as `f32`. Lighting and blending math is correct
|
||||
only in linear space, so that is the engine's working space.
|
||||
- Values are nominally in `[0, 1]` but are **not clamped** — values above `1.0`
|
||||
represent HDR / emissive intensity.
|
||||
- Conversions to and from 8-bit **sRGB** (the space of color pickers, image
|
||||
files, and `#RRGGBB` hex) are explicit: `Color::from_srgb_u8`,
|
||||
`Color::from_hex`, `Color::to_srgb_u8`. Never treat raw 8-bit values as linear.
|
||||
|
||||
## Geometry primitives
|
||||
|
||||
- An [`Aabb`](math.md#aabb) is *empty* when any `min` component exceeds the
|
||||
corresponding `max`; `Aabb::EMPTY` is the identity for `union`.
|
||||
- A [`Ray`](math.md#ray) always stores a **normalized** direction, so its
|
||||
parameter `t` is a true distance.
|
||||
- A [`Plane`](math.md#plane) is stored in **Hessian normal form** (`normal·p + d
|
||||
= 0`) with a unit normal; the positive half-space is the side the normal points
|
||||
toward.
|
||||
- A [`Frustum`](math.md#frustum) stores six planes with **inward-facing**
|
||||
normals; a point is inside when it is in the positive half-space of all six.
|
||||
|
||||
## Determinism
|
||||
|
||||
Procedural systems (Stage 11+) must be **deterministic**: the same seed always
|
||||
produces the same output. Tests that need randomness use a small, explicit,
|
||||
seeded PRNG rather than a system RNG, so failures are reproducible.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Development Workflow
|
||||
|
||||
How work flows through the Oxide project: branches, testing gates, documentation,
|
||||
and how a stage progresses from start to sign-off.
|
||||
|
||||
## Branch model
|
||||
|
||||
Oxide uses two long-lived branches:
|
||||
|
||||
| Branch | Meaning |
|
||||
|--------|---------|
|
||||
| `dev` | Integration branch. Everything that builds and passes automated tests lands here first. |
|
||||
| `main` | Stable branch. Only contains work that has been verified — automatically *and*, where relevant, manually approved. |
|
||||
|
||||
### The flow
|
||||
|
||||
1. **Work lands on `dev`.** Any change that can be **fully verified by automated
|
||||
means** (it builds, and its unit / integration / fuzz tests and benchmarks
|
||||
pass) is committed and pushed to `dev` as soon as it is complete and green.
|
||||
|
||||
2. **Manual-test gate before `main`.** If a change *cannot* be fully verified
|
||||
automatically — anything involving the GUI, rendering output, audio, input
|
||||
feel, or otherwise "you have to actually run the engine and look at it" — it
|
||||
stops at `dev`. The maintainer runs it, reviews the behavior, and reports
|
||||
back. Only after explicit approval is that version promoted to `main`.
|
||||
|
||||
3. **Fully-automated changes can go straight to both.** When a change is
|
||||
completely covered by automated tests (e.g. the math module — pure CPU logic
|
||||
with full unit/fuzz/benchmark coverage), it may be pushed to `dev` and `main`
|
||||
together, because the automated suite *is* the sign-off. No manual gate is
|
||||
needed.
|
||||
|
||||
### Deciding which path a change takes
|
||||
|
||||
Ask: **"Can a test prove this works without a human looking at it?"**
|
||||
|
||||
- **Yes** → it can go to `main` as soon as tests pass (via `dev`).
|
||||
- **No** (needs eyes/ears on a running engine) → push to `dev`, request manual
|
||||
testing, wait for approval, then promote to `main`.
|
||||
|
||||
When in doubt, treat it as needing manual testing and leave it on `dev`.
|
||||
|
||||
### Promoting `dev` to `main`
|
||||
|
||||
```sh
|
||||
git checkout main
|
||||
git merge --ff-only dev # or a regular merge if histories diverged
|
||||
git push origin main
|
||||
git checkout dev
|
||||
```
|
||||
|
||||
## Testing protocol
|
||||
|
||||
Every stage must pass all of the following before it is considered done (see also
|
||||
[`PLAN.md`](../PLAN.md)):
|
||||
|
||||
1. **Unit tests** — every module has tests for core behavior and edge cases,
|
||||
living in a `#[cfg(test)] mod tests` block beside the code.
|
||||
2. **Integration tests** — the `oxide-tests` crate runs end-to-end and
|
||||
cross-module scenarios, including fuzz/property tests where appropriate.
|
||||
3. **Example review** — each stage ships at least one runnable example in
|
||||
`oxide-examples`; both the maintainer and the implementer review it.
|
||||
4. **Benchmarks** — performance-sensitive systems have `criterion` benchmarks;
|
||||
regressions against a stage's stated budget block sign-off.
|
||||
5. **Clippy + fmt** — `cargo clippy --all-targets -- -D warnings` and
|
||||
`cargo fmt --check` must be clean. Engine/editor crates use
|
||||
`#![deny(warnings)]`.
|
||||
|
||||
Quick local gate before any commit:
|
||||
|
||||
```sh
|
||||
cargo fmt --check && \
|
||||
cargo clippy --all-targets -- -D warnings && \
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Documentation policy
|
||||
|
||||
Documentation is written **as the work is done**, not after:
|
||||
|
||||
- Detailed docs live in [`docs/`](README.md), one topic per file. The top-level
|
||||
`README.md` stays a short overview.
|
||||
- A stage is not complete until its systems are documented in `docs/` — usage
|
||||
(how to call it) **and** inner workings (how/why it works).
|
||||
- When an API changes, update the affected doc and its code snippets in the
|
||||
**same change**, so docs never drift from the code.
|
||||
- Keep the maintained project files current whenever structure, goals, or
|
||||
process change: `CLAUDE.md`, `PLAN.md`, `README.md`, `.gitignore`, and the
|
||||
relevant files in `docs/`.
|
||||
|
||||
## Adding a new stage
|
||||
|
||||
1. Read [`PLAN.md`](../PLAN.md) for the stage's deliverables and test criteria.
|
||||
2. Implement the system as one or more focused modules under `engine/src/`
|
||||
(one concept per file, tests beside the code). Add editor support if the
|
||||
stage calls for it.
|
||||
3. Add at least one example under `examples/src/bin/`.
|
||||
4. Write the stage's documentation under `docs/` and link it from
|
||||
[`docs/README.md`](README.md).
|
||||
5. Ensure the full testing protocol passes.
|
||||
6. Update `PLAN.md` (mark the stage complete), `README.md` (status/features),
|
||||
and — if installed binaries or assets changed — `install.sh`.
|
||||
7. Land on `dev`; promote to `main` per the branch model above.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
- Commit messages are written in the imperative mood and describe *what* and
|
||||
*why*.
|
||||
- Group related changes; keep a commit focused on one logical change where
|
||||
practical.
|
||||
- Co-authorship trailers are added when a commit is produced with AI assistance.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Editor Extension API
|
||||
|
||||
`oxide_editor::extension` is the editor-side companion to the engine's
|
||||
[`Module`](modules.md) trait. It is the mechanism through which a module
|
||||
contributes the UI it needs the editor to host on its behalf — menu items,
|
||||
dockable panels, viewport tools, component inspectors, and Preferences
|
||||
pages — **without editing the editor's source**.
|
||||
|
||||
This is *the* extension surface for both first-party modules (Stage-7 input
|
||||
binding pages, Stage-9 physics inspectors, Stage-17 ray-traced-audio panels…)
|
||||
and any third-party or AI-authored module.
|
||||
|
||||
## Why a separate trait
|
||||
|
||||
The engine doesn't depend on egui — putting the editor hook on
|
||||
`oxide_engine::app::Module` would pull egui into the engine. Instead a module
|
||||
that wants to participate in the editor implements **two** traits on the same
|
||||
struct:
|
||||
|
||||
```rust
|
||||
struct AudioPreviewModule;
|
||||
|
||||
impl oxide_engine::app::Module for AudioPreviewModule {
|
||||
fn name(&self) -> &'static str { "audio_preview" }
|
||||
fn build(&self, app: &mut oxide_engine::app::App) {
|
||||
// … register systems, types, asset loaders
|
||||
}
|
||||
}
|
||||
|
||||
impl oxide_editor::extension::EditorModule for AudioPreviewModule {
|
||||
fn name(&self) -> &'static str { "audio_preview" }
|
||||
fn build_editor(&self, ext: &mut oxide_editor::extension::EditorExtensions) {
|
||||
// … register menu items, panels, inspectors, settings pages
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The shared name is how enable/disable in Preferences stays consistent across
|
||||
the two halves: toggling `"audio_preview"` hides the editor contributions and
|
||||
disables the engine systems together.
|
||||
|
||||
## What a module can contribute
|
||||
|
||||
| Kind | Helper | Stage-6 criterion |
|
||||
|------|--------|-------------------|
|
||||
| **Menu items** (`File/New`, `Help/About`, …) | `add_menu_item` | ✔ required |
|
||||
| **Dockable panels** | `add_panel` | ✔ required |
|
||||
| **Viewport tools** (gizmos, brushes) | `add_viewport_tool` | future stages |
|
||||
| **Component inspectors** (by reflection name) | `add_inspector` | future stages |
|
||||
| **Settings pages** (by section name) | `add_settings_page` | ✔ required |
|
||||
|
||||
```rust,no_run
|
||||
use oxide_editor::extension::{DockLocation, EditorExtensions, EditorModule};
|
||||
|
||||
struct DemoModule;
|
||||
impl EditorModule for DemoModule {
|
||||
fn name(&self) -> &'static str { "demo" }
|
||||
fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
ext.add_menu_item("File/Demo…", || { /* open the demo dialog */ });
|
||||
ext.add_panel("Demo Panel", DockLocation::Right, |ui| {
|
||||
ui.label("hello from a module-owned panel");
|
||||
});
|
||||
ext.add_inspector("DemoComponent", |ui| {
|
||||
ui.label("custom editor for DemoComponent");
|
||||
});
|
||||
ext.add_settings_page("demo", "Demo", |ui| {
|
||||
ui.label("module preferences here");
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The render closures take only `&mut egui::Ui` in Piece 5 (registration). Piece
|
||||
6 — the docking shell — refines the signatures to pass through the editor's
|
||||
runtime context (scene, selection, asset server, settings). Modules that need
|
||||
shared state today can capture it through interior mutability
|
||||
(`Rc<RefCell<...>>`).
|
||||
|
||||
## How the shell consumes the registry
|
||||
|
||||
```rust
|
||||
# use oxide_editor::extension::{EditorExtensions, EditorModule, DockLocation};
|
||||
# struct M; impl EditorModule for M {
|
||||
# fn name(&self) -> &'static str { "m" }
|
||||
# fn build_editor(&self, ext: &mut EditorExtensions) {
|
||||
# ext.add_menu_item("File/Open", || {});
|
||||
# ext.add_panel("Inspector", DockLocation::Right, |_| {});
|
||||
# }
|
||||
# }
|
||||
let mut ext = EditorExtensions::new();
|
||||
ext.add_module(M);
|
||||
|
||||
// What the shell will do in piece 6:
|
||||
for item in ext.iter_menu_items() {
|
||||
let _ = &item.path; // build the menu tree
|
||||
}
|
||||
for panel in ext.iter_panels() {
|
||||
let _ = (&panel.name, panel.default_dock); // place in dock layout
|
||||
}
|
||||
```
|
||||
|
||||
Lookups by name are also provided (`has_inspector_for("Transform")`,
|
||||
`has_settings_page_for("audio")`) so the Inspector and Preferences windows can
|
||||
ask "is there a custom editor for this thing?" before rendering a fallback.
|
||||
|
||||
## Attribution and lifecycle
|
||||
|
||||
Every contribution remembers its source module. That gives three lifecycle
|
||||
operations the engine `App` already has and the editor needs to mirror:
|
||||
|
||||
| Operation | Effect |
|
||||
|-----------|--------|
|
||||
| `add_module` | Runs `build_editor`, attributes every contribution to the module, marks enabled. Re-adding replaces the old registration cleanly. |
|
||||
| `set_module_enabled(name, false)` | Contributions stay registered but vanish from every `iter_*` / `has_*` lookup — toggling Preferences is reversible without rebuilding state. |
|
||||
| `remove_module(name)` | Drops every contribution attributed to the module in one shot. |
|
||||
|
||||
Adding contributions outside a module's `build_editor` panics: every entry
|
||||
must be attributable to *some* module, otherwise removal would leave orphans.
|
||||
|
||||
## Inspector / settings-page resolution
|
||||
|
||||
The Inspector panel renders custom editors for components whose type has a
|
||||
registered inspector — keyed by the same name the component is registered
|
||||
under in the [reflection registry](reflection.md). Settings pages plug into the
|
||||
[Preferences framework](settings.md) by matching their `section_name` to the
|
||||
section the module registered. Both lookups respect the enabled flag, so a
|
||||
disabled module's inspector / page disappears even if the underlying section
|
||||
or type is still registered.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
The whole API is purely about *registration*, so it's directly unit-testable
|
||||
without bringing up egui — tests construct an `EditorExtensions`, add a
|
||||
demo module, and assert via the lookup helpers. The actual rendering of the
|
||||
contributed UI is exercised by the Piece-6 docking shell with a maintainer
|
||||
manual pass; the Stage-6 criterion ("a trivial test module adds a menu item,
|
||||
a panel, and a settings page through the API with no editor-core edits") is
|
||||
covered by the integration test in `tests/src/lib.rs::stage6`.
|
||||
|
||||
[`extension`]: ../editor/src/extension.rs
|
||||
[`EditorExtensions`]: ../editor/src/extension.rs
|
||||
[`EditorModule`]: ../editor/src/extension.rs
|
||||
@@ -0,0 +1,181 @@
|
||||
# Editor Shell
|
||||
|
||||
The Stage-6 docking **shell** is the editor's host frame: the top menu bar,
|
||||
the bottom status bar, the dockable panel area, the Preferences window, and
|
||||
the wiring between every Stage-6 framework piece — command stack, project
|
||||
system, settings, file watcher, and the
|
||||
[module → editor extension API](editor-extensions.md).
|
||||
|
||||
Lives in [`oxide_editor::shell`](../editor/src/shell.rs) (the library) with
|
||||
[`oxide-editor`](../editor/src/main.rs) (the binary) acting as glue: open a
|
||||
window, run the egui paint pump, run the 3D viewport, hand events to the
|
||||
shell. Splitting the shell into the library lets it be unit-tested without
|
||||
spinning up a window.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ File Edit View Project Modules Help ⚙ Prefs │ ← menu bar
|
||||
├────────────┬───────────────────────────┬────────────────────┤
|
||||
│ │ │ │
|
||||
│ Hierarchy │ Viewport │ Inspector │
|
||||
│ │ │ │
|
||||
│ ├───────────────────────────┤ │
|
||||
│ │ Project │ Console │ │
|
||||
│ │ │ │
|
||||
├────────────┴───────────────────────────┴────────────────────┤
|
||||
│ Reloaded 3 asset(s) modules: 0 undo: 2 │ ← status bar
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Panels are dockable: drag a tab to re-dock, resize splits, or pop it out as a
|
||||
floating window — provided by [`egui_dock`](https://docs.rs/egui_dock). The
|
||||
default layout is built once in [`Shell::default_dock`]; persisting the user's
|
||||
layout across restarts is a later refinement.
|
||||
|
||||
## What's wired to what
|
||||
|
||||
| Shell surface | Backing system |
|
||||
|---------------|----------------|
|
||||
| File menu → New / Open / Save / Recent / Quit | [`Project`](projects.md) + `RecentProjects` + `Shell::take_quit_request` |
|
||||
| Edit menu → Undo / Redo (Ctrl+Z / Ctrl+Y) | [`CommandStack`](../editor/src/command.rs) |
|
||||
| View menu → Show/Hide panel toggles | dock state |
|
||||
| Project menu → Open project info | `EditorState::project` |
|
||||
| Modules menu → module-contributed items | [`EditorExtensions`](editor-extensions.md) |
|
||||
| Help → About | static info |
|
||||
| Status bar → live hint (last action, errors) | `StatusLine` (timed TTL) |
|
||||
| Status bar → module / undo counters | `EditorExtensions` + `CommandStack` |
|
||||
| Preferences window → settings sections + module on/off | [`Settings`](settings.md) + `EditorExtensions` |
|
||||
| File watcher (on project open) → `AssetServer::reload_path` | [`FileWatcher`](file-watching.md) |
|
||||
|
||||
## Commands and undo
|
||||
|
||||
`Edit` menu shows the labels of the next undo / redo entry; `Ctrl+Z` /
|
||||
`Ctrl+Y` (also `Ctrl+Shift+Z`) drive them. The shortcut router is
|
||||
[`Shell::try_consume_shortcut`] — same path the menu uses, so the unit tests
|
||||
exercise the real flow.
|
||||
|
||||
The first wired commands ([`SetTransformCmd`], [`RenameCmd`]) live in
|
||||
[`oxide_editor::commands`](../editor/src/commands.rs).
|
||||
[`SetTransformCmd::merge`] coalesces consecutive edits to the same entity, so
|
||||
a slider drag (or a future gizmo drag) is **one** undo entry instead of one
|
||||
per frame.
|
||||
|
||||
Structural edits (spawn / despawn / reparent / change mesh) still bypass the
|
||||
stack today — round-tripping a despawn through undo needs stable entity ids,
|
||||
a Stage-7 design step alongside the gizmos.
|
||||
|
||||
## File watcher
|
||||
|
||||
[`Shell::open_project`] (or `create_project`) attaches a
|
||||
[`FileWatcher`](file-watching.md) over the project's `assets/`, `scenes/`, and
|
||||
`scripts/` directories (~150 ms debounce window). The shell's
|
||||
[`frame_tick`](../editor/src/shell.rs) pumps events through
|
||||
[`reload_changed_assets`](file-watching.md) each frame, so editing a file
|
||||
externally hot-reloads any handle that was already loaded. Closing the
|
||||
project tears the watcher down.
|
||||
|
||||
Backends that don't deliver events (some sandboxed CI environments) log a
|
||||
warning and let the editor keep running — the watcher is best-effort.
|
||||
|
||||
## Module integration
|
||||
|
||||
Anything a module registers via the [extension API](editor-extensions.md) is
|
||||
hosted by the shell with no editor-source edits:
|
||||
|
||||
- **Menu items** appear under the `Modules` top menu (shown only when at
|
||||
least one item is registered).
|
||||
- **Panels** appear in the dock as `PanelKind::Custom(name)` tabs, rendered
|
||||
through the module's `FnMut(&mut egui::Ui)` closure.
|
||||
- **Settings pages** + module on/off checkboxes are surfaced in the
|
||||
Preferences window's sidebar.
|
||||
- **Component inspectors** (Stage 7+) will be looked up by reflection name
|
||||
when the Inspector encounters a selection holding that component.
|
||||
|
||||
Disabling a module in Preferences hides every contribution at once but keeps
|
||||
it registered — re-enabling restores it instantly, no shell rebuild.
|
||||
|
||||
## Why a `Shell` library
|
||||
|
||||
A few reasons it lives in `editor/src/shell.rs` rather than `main.rs`:
|
||||
|
||||
- **Unit-testable behavior.** Shortcut routing, project open/close, recent
|
||||
list updates, command stack lifecycle — all exercised without a window.
|
||||
The maintainer's manual pass focuses on what tests *can't* prove: how the
|
||||
UI looks and feels.
|
||||
- **Reusable in tests and future hosts.** A headless reproducer for a UI bug
|
||||
can drive the shell directly; an alternate front-end (web, embedded) could
|
||||
reuse it.
|
||||
- **Separation of concerns.** The binary stays a thin runner — window event
|
||||
loop, 3D viewport, egui paint pump — while the shell owns editor state,
|
||||
layout, and the framework wiring.
|
||||
|
||||
## Viewport camera modes (Stage 7)
|
||||
|
||||
The viewport has two camera schemes; the active one is toggled with
|
||||
**F** (the default binding for the `editor.camera.toggle_flythrough`
|
||||
action) while the cursor is over the Viewport tab. Bindings live in
|
||||
`EditorState::actions` ([`ActionMap`](input.md)) registered with editor
|
||||
defaults at startup; the [Input Bindings](#input-bindings-preferences-page)
|
||||
preferences page exposes them for remapping.
|
||||
|
||||
| Mode | Controls |
|
||||
|------|----------|
|
||||
| **Orbit** (default) | L-drag = orbit · R-drag = pan · scroll = zoom · click = pick |
|
||||
| **Flythrough** | WASD = forward/back + strafe · QE = down/up · Shift = sprint · R-drag = look · scroll = adjust move speed · click = pick |
|
||||
|
||||
Toggling preserves pose: the new camera lands looking at the same view
|
||||
the previous one was showing, so the scene doesn't snap.
|
||||
|
||||
## Input Bindings preferences page (Stage 7 piece 5)
|
||||
|
||||
The Preferences window's `input.bindings` section renders a rich page
|
||||
listing every registered editor action — buttons, 1D axes, 2D axes — with
|
||||
its current bindings. Each binding cell:
|
||||
|
||||
- Clicking it arms a **capture** for that slot. The page shows
|
||||
"Press a key…"; the next non-`Escape` key or mouse button press
|
||||
becomes the binding. `Escape` cancels.
|
||||
- `✕` removes that binding.
|
||||
- `+` (per direction or per action) starts an append capture so the user
|
||||
can add a binding without replacing one.
|
||||
- `↺` (per action) restores that action to its code-defined defaults.
|
||||
- A global **Restore all defaults** button at the top resets every
|
||||
action.
|
||||
|
||||
Edits flow through [`Shell`](../editor/src/shell.rs)'s
|
||||
`try_complete_capture`, which mutates `EditorState::actions`, syncs the
|
||||
new bindings into the `input.bindings` settings section via
|
||||
`sync_action_overrides_to_settings`, and flips a `bindings_dirty` flag.
|
||||
The host runner reads-and-clears the flag each frame and writes the
|
||||
preferences file to `$XDG_CONFIG_HOME/oxide/editor.ron` (or
|
||||
`$HOME/.config/oxide/editor.ron`). On startup the editor reads that
|
||||
file, calls `Settings::import`, and `apply_action_overrides_from_settings`
|
||||
layers the user's remap on top of the defaults — so a remap survives a
|
||||
restart, and removing an action from code never breaks an old file
|
||||
(unknown sections are silently skipped).
|
||||
|
||||
The page is intentionally a built-in Shell feature rather than going
|
||||
through `EditorExtensions::add_settings_page`: it needs to mutate
|
||||
`EditorState::actions` while a capture is in flight, which is more
|
||||
direct from the Shell than through the extension API's `FnMut(&mut Ui)`
|
||||
contract.
|
||||
|
||||
## What's not yet here
|
||||
|
||||
| Feature | Where it lands |
|
||||
|---------|---------------|
|
||||
| Native New/Open dialogs (`rfd` or similar) | Polish; the in-app text-path modals fill the gap today |
|
||||
| ~~3D viewport with its own projection sized to the Viewport tab~~ | ✅ Landed in Stage 7 piece 6b: `FrameContext::viewport_rect` restricts the wgpu viewport and drives the projection aspect; `Viewport::pick` rebases the cursor to tab-local NDC. |
|
||||
| Layout persistence across restarts | After settings sections are richer (Preferences-driven) |
|
||||
| Inspector via reflection-keyed component editors | Stage 7 alongside the gizmos |
|
||||
| Undo/redo for spawn/despawn/reparent | Stage 7 — needs stable entity ids |
|
||||
| Console wired to a real log feed / Stage-10 terminal | Stage 10 |
|
||||
|
||||
[`Shell::default_dock`]: ../editor/src/shell.rs
|
||||
[`Shell::try_consume_shortcut`]: ../editor/src/shell.rs
|
||||
[`Shell::open_project`]: ../editor/src/shell.rs
|
||||
[`SetTransformCmd`]: ../editor/src/commands.rs
|
||||
[`SetTransformCmd::merge`]: ../editor/src/commands.rs
|
||||
[`RenameCmd`]: ../editor/src/commands.rs
|
||||
@@ -0,0 +1,154 @@
|
||||
# File Watching
|
||||
|
||||
`oxide_engine::watch` watches directories on disk and emits **debounced**,
|
||||
**deduplicated** change events. It is the Stage-6 foundation for the engine's
|
||||
live-reload story:
|
||||
|
||||
- **Stage 6** — reload changed assets (via [`AssetServer`](assets.md)) so a
|
||||
texture or model edited in an external tool reappears in the running editor
|
||||
without restarting.
|
||||
- **Stage 10** — recompile and hot-swap game scripts using the same event
|
||||
stream and the same debounce logic.
|
||||
- **Editor** — drives the Project panel's "files appeared / disappeared"
|
||||
refresh.
|
||||
|
||||
The same module covers all of these because the hard part — "wait until the
|
||||
filesystem stops twitching, then emit one event per path" — is identical in
|
||||
every case.
|
||||
|
||||
## Why debounce
|
||||
|
||||
Filesystem events are noisy:
|
||||
|
||||
- Most editors save in several syscalls (write the file, rename a temp file
|
||||
into place, chmod) — that is one logical change but several events.
|
||||
- Recursive watches re-fire while a directory's children are being created.
|
||||
- Backends collapse or split events differently across Linux, macOS, and
|
||||
Windows.
|
||||
|
||||
If the engine reloaded on every raw event, one save could re-parse a model many
|
||||
times over. The watcher gathers raw events into a **pending set** keyed by
|
||||
path, then emits one event per path once that path has been **quiet** for a
|
||||
configurable window.
|
||||
|
||||
## Architecture (two layers)
|
||||
|
||||
The module is intentionally split so most behavior is unit-testable without
|
||||
touching real files.
|
||||
|
||||
### `Debouncer` — the pure core
|
||||
|
||||
A plain struct that takes `Instant`s from the caller. Tests drive it through a
|
||||
deterministic timeline; no `sleep`, no flaky timing dependence on the OS event
|
||||
queue.
|
||||
|
||||
```rust
|
||||
use std::time::{Duration, Instant};
|
||||
use oxide_engine::watch::{ChangeKind, Debouncer};
|
||||
|
||||
let mut d = Debouncer::new(Duration::from_millis(100));
|
||||
let t0 = Instant::now();
|
||||
|
||||
d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0);
|
||||
d.record("assets/cube.gltf".into(), ChangeKind::Modified,
|
||||
t0 + Duration::from_millis(20));
|
||||
|
||||
// Still hot — nothing fires.
|
||||
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
|
||||
|
||||
// After 100 ms of quiet, one event fires for the path.
|
||||
let ready = d.drain_ready(t0 + Duration::from_millis(130));
|
||||
assert_eq!(ready.len(), 1);
|
||||
```
|
||||
|
||||
Coalescing rules (chosen to match what a reloader downstream cares about):
|
||||
|
||||
| Earlier kind | Newer kind | Emitted kind |
|
||||
|--------------|-----------|--------------|
|
||||
| `Created` | `Modified` | `Created` |
|
||||
| `Removed` | `Modified` | `Created` (file came back) |
|
||||
| anything | `Removed` | `Removed` |
|
||||
| anything else | newer | newer |
|
||||
|
||||
### `FileWatcher` — the real-world wrapper
|
||||
|
||||
Wraps a `notify::RecommendedWatcher` plus a worker thread that drives the
|
||||
debouncer with real time and forwards settled events through an `mpsc` channel.
|
||||
|
||||
```rust,no_run
|
||||
use std::time::Duration;
|
||||
use oxide_engine::watch::FileWatcher;
|
||||
|
||||
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||
watcher.watch("path/to/project/assets")?;
|
||||
|
||||
// In the editor's per-frame tick, drain whatever has settled:
|
||||
while let Ok(event) = events.try_recv() {
|
||||
println!("{:?} at {}", event.kind, event.path.display());
|
||||
}
|
||||
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||
```
|
||||
|
||||
`FileWatcher` watches recursively. Dropping it stops the worker thread and
|
||||
disconnects the receiver — no manual cleanup.
|
||||
|
||||
The quiet window is a knob: too short and you get repeated events from one
|
||||
save; too long and the editor feels laggy. The default Stage-6 wiring uses
|
||||
~150 ms.
|
||||
|
||||
## Asset reload
|
||||
|
||||
`reload_changed_assets` is the wiring between the watcher and the
|
||||
[asset server](assets.md). For each `Created` or `Modified` event it calls
|
||||
`AssetServer::reload_path`, which re-runs the loader for every cached asset at
|
||||
that path and updates the existing handle **in place** — gameplay code holding
|
||||
the handle sees the new contents on its next read.
|
||||
|
||||
```rust,no_run
|
||||
use std::time::Duration;
|
||||
use oxide_engine::asset::AssetServer;
|
||||
use oxide_engine::watch::{reload_changed_assets, FileWatcher};
|
||||
|
||||
let assets = AssetServer::new();
|
||||
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
|
||||
watcher.watch("path/to/project/assets")?;
|
||||
|
||||
// Per frame:
|
||||
let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect();
|
||||
let reloaded = reload_changed_assets(&assets, batch);
|
||||
if reloaded > 0 {
|
||||
log::info!("hot-reloaded {} asset(s)", reloaded);
|
||||
}
|
||||
# Ok::<(), oxide_engine::watch::WatchError>(())
|
||||
```
|
||||
|
||||
`AssetServer::reload_path` is type-erased on purpose. The cache records, per
|
||||
entry, a function pointer that re-runs the loader for that entry's concrete
|
||||
type, so the watcher can react to a disk change without knowing every asset
|
||||
type at compile time. Paths that are not currently cached return zero work;
|
||||
the next `load` picks up the fresh contents anyway. `Removed` events do **not**
|
||||
invalidate cached handles — gameplay code may want the last-loaded copy to
|
||||
keep working.
|
||||
|
||||
## What this groundwork enables
|
||||
|
||||
| Stage | Builds on |
|
||||
|-------|-----------|
|
||||
| 6 | Editor live-reload of assets; Project panel refresh |
|
||||
| 7 | Watch input-binding config for changes during a session |
|
||||
| 10 | Script hot-reload (same watcher; the reloader recompiles + swaps the module) |
|
||||
| 11 | WGSL shader hot-reload |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit tests** drive `Debouncer` directly with fixed `Instant`s — fast,
|
||||
deterministic, and they cover the coalescing rules exhaustively.
|
||||
- **One tolerant smoke test** writes to a temp dir and polls for an event with
|
||||
a generous deadline (seconds, not milliseconds). On containerized CI without
|
||||
a usable event backend the test prints `SKIP:` and passes — the unit tests
|
||||
already prove the logic is correct, this only checks the OS wiring is
|
||||
connected.
|
||||
|
||||
[`watch`]: ../engine/src/watch.rs
|
||||
[`Debouncer`]: ../engine/src/watch.rs
|
||||
[`FileWatcher`]: ../engine/src/watch.rs
|
||||
@@ -0,0 +1,113 @@
|
||||
# Getting Started
|
||||
|
||||
This guide takes you from a clean machine to building Oxide, running an example,
|
||||
and running the test and benchmark suites.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Rust stable toolchain.** Install via [rustup](https://rustup.rs):
|
||||
```sh
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
Oxide tracks the stable channel and sets a minimum supported Rust version
|
||||
(MSRV) in the workspace `Cargo.toml` (`rust-version`).
|
||||
- **Linux** is the primary target. Other platforms are not yet tested.
|
||||
- **A GPU with Vulkan support** (`wgpu` is integrated since Stage 2; on Linux
|
||||
the Vulkan backend is the default). Backend selection can be overridden with
|
||||
the `WGPU_BACKEND` environment variable.
|
||||
|
||||
## Cloning
|
||||
|
||||
```sh
|
||||
git clone https://git.houmeres.sk/Houmeres/Oxide.git
|
||||
cd Oxide
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Build the whole workspace (engine, editor, examples, tests):
|
||||
|
||||
```sh
|
||||
cargo build # debug
|
||||
cargo build --release # optimized
|
||||
```
|
||||
|
||||
The workspace is a Cargo workspace with these members:
|
||||
|
||||
- `oxide-engine` — the core library
|
||||
- `oxide-editor` — the in-engine editor binary
|
||||
- `oxide-examples` — runnable examples (one or more per stage)
|
||||
- `oxide-tests` — the integration test harness
|
||||
|
||||
## Running the editor
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-editor --release
|
||||
```
|
||||
|
||||
The editor opens its own window with a placeholder viewport (Stage 2); editor
|
||||
panels arrive with the systems they edit in later stages. Quit with `Ctrl+Q`.
|
||||
Raw input logging is visible with `RUST_LOG=debug`.
|
||||
|
||||
## Running examples
|
||||
|
||||
Examples live in `examples/src/bin/` and each is a standalone binary:
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin math_demo
|
||||
```
|
||||
|
||||
| Example | Stage | Description |
|
||||
|---------|-------|-------------|
|
||||
| `math_demo` | 1 | Prints a tour of transforms, bounds, ray/plane queries, frustum culling, color, and value ranges |
|
||||
| `hello_window` | 2 | Opens a window cleared to a configurable color — keys `1`–`5` pick presets, `Space` cycles, `Esc` quits; FPS logged once per second |
|
||||
|
||||
## Running tests
|
||||
|
||||
```sh
|
||||
cargo test # the whole workspace
|
||||
cargo test -p oxide-engine # engine unit tests only
|
||||
cargo test -p oxide-tests # integration tests only
|
||||
```
|
||||
|
||||
Unit tests live next to the code they test (a `#[cfg(test)] mod tests` block in
|
||||
each module). End-to-end and cross-cutting tests live in the `oxide-tests` crate.
|
||||
|
||||
## Running benchmarks
|
||||
|
||||
Performance-sensitive systems use [criterion](https://github.com/bheisler/criterion.rs):
|
||||
|
||||
```sh
|
||||
cargo bench -p oxide-engine
|
||||
```
|
||||
|
||||
Stage 1 ships the `transform` benchmark, which includes `compose_1m` (the Stage 1
|
||||
budget is 1,000,000 transform compositions in under 10 ms).
|
||||
|
||||
## Lint and format
|
||||
|
||||
Both must be clean before any change is committed:
|
||||
|
||||
```sh
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
The engine and editor crates are compiled with `#![deny(warnings)]`, so warnings
|
||||
are hard errors.
|
||||
|
||||
## Installing to the system (Linux)
|
||||
|
||||
```sh
|
||||
chmod +x install.sh
|
||||
./install.sh # installs to /usr/local
|
||||
PREFIX=$HOME/.local ./install.sh # custom prefix
|
||||
```
|
||||
|
||||
This builds in release mode and installs the `oxide-editor` binary plus assets.
|
||||
To uninstall:
|
||||
|
||||
```sh
|
||||
sudo rm /usr/local/bin/oxide-editor
|
||||
sudo rm -rf /usr/local/share/oxide
|
||||
```
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
# Input
|
||||
|
||||
Stage 7 reference for `oxide_engine::input` — the engine's input
|
||||
abstraction. Three layers live here today:
|
||||
|
||||
- **Piece 1: [`InputState`](#raw-input-state)** — the raw per-frame
|
||||
snapshot (keyboard / mouse / cursor / scroll, with edge detection).
|
||||
- **Piece 2: [`Binding`] + [`ActionMap`](#named-actions-and-remapping)** —
|
||||
named actions (e.g. `"Jump"`) bound to one or more physical inputs, with
|
||||
defaults, runtime remapping, and RON-persistable user overrides.
|
||||
- **Piece 3: [`AxisBinding`] + [`Axis2DBinding`](#directional-axes)** —
|
||||
directional inputs composed from `Binding` direction sets (e.g. `WASD` →
|
||||
`Vec2 "Move"`), stored alongside button actions in the same `ActionMap`
|
||||
and persisted through the same `ActionOverrides` payload.
|
||||
|
||||
The editor pieces (flythrough camera, bindings preferences page, transform
|
||||
gizmos) layer on top of these.
|
||||
|
||||
For the raw [`WindowEvent`](windowing.md) vocabulary the runner pumps from,
|
||||
see [windowing.md](windowing.md).
|
||||
|
||||
## Raw input state
|
||||
|
||||
### Why a separate layer
|
||||
|
||||
Game code wants three distinct things from a physical key:
|
||||
|
||||
- **The press edge** — fires *once* on the frame a key first goes down. A
|
||||
jump fires here.
|
||||
- **The release edge** — fires *once* on the frame a key comes back up. A
|
||||
charged shot fires here.
|
||||
- **The held state** — true every frame between press and release. A sprint
|
||||
modifier reads this.
|
||||
|
||||
Reading these straight off `WindowEvent::KeyboardInput` is doable but error-
|
||||
prone: OS key auto-repeat re-sends `Pressed` on every repeat, focus loss can
|
||||
leave keys "held" with no matching release, and a `CursorMoved` carries no
|
||||
delta unless the consumer remembers the previous position. `InputState`
|
||||
solves all of that in one place, and its semantics are unit-tested.
|
||||
|
||||
## How the runner uses it
|
||||
|
||||
The windowing [`run`](windowing.md) loop owns one `InputState` and:
|
||||
|
||||
1. Pumps every incoming [`WindowEvent`](windowing.md) into it via
|
||||
`InputState::handle_event` **before** any callback sees the event, so
|
||||
`ctx.input()` in `WindowApp::event` already reflects the event being
|
||||
delivered.
|
||||
2. Calls `WindowApp::update` — game logic reads `ctx.input()` to query the
|
||||
accumulated state for the frame.
|
||||
3. After `update` returns, calls `InputState::end_frame` to roll edges and
|
||||
per-frame deltas off. Held state and the cursor anchor persist.
|
||||
|
||||
The result: in `update`, edges describe what happened "since the previous
|
||||
frame" and held state is "right now".
|
||||
|
||||
## Reading input from a `WindowApp`
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::winit::event::MouseButton;
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MyApp;
|
||||
|
||||
impl WindowApp for MyApp {
|
||||
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let input = ctx.input();
|
||||
|
||||
if input.pressed(KeyCode::Space) {
|
||||
// Fires once, on the frame Space went down.
|
||||
}
|
||||
if input.held(KeyCode::ShiftLeft) {
|
||||
// True every frame Shift is down.
|
||||
}
|
||||
if input.released(KeyCode::Escape) {
|
||||
ctx.request_exit();
|
||||
}
|
||||
|
||||
// Right-drag pans by the mouse delta accumulated this frame.
|
||||
if input.mouse_held(MouseButton::Right) {
|
||||
let _delta = input.mouse_delta(); // physical pixels
|
||||
}
|
||||
|
||||
// Scroll is in line-equivalent units (touchpad pixels are normalized
|
||||
// so wheels and trackpads report on the same scale).
|
||||
let _zoom_amount = input.scroll().y;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Edge semantics, in detail
|
||||
|
||||
`InputState` keeps three sets per device (held / pressed / released) and
|
||||
applies these rules:
|
||||
|
||||
- `press_key(k)` — if `k` was **not** already held, both `held` and
|
||||
`pressed` add it. If it was already held (OS auto-repeat), `pressed` is
|
||||
unchanged. The one-shot press edge fires exactly once per real keypress.
|
||||
- `release_key(k)` — `held` removes `k`; `released` adds `k`. The release
|
||||
edge fires whether or not the key was previously tracked as held, so the
|
||||
occasional "release without matching press" the OS delivers (focus
|
||||
changes, alt-tab) still produces a usable signal.
|
||||
- `end_frame()` — clears `pressed` and `released` (and the per-frame mouse
|
||||
delta + scroll). `held` and the cursor anchor are untouched.
|
||||
- `WindowEvent::Focused(false)` — every currently-held key and mouse button
|
||||
is force-released (released-edge fires for each), so a key held when the
|
||||
user alt-tabbed away cannot remain stuck after the window comes back.
|
||||
|
||||
Mouse buttons mirror the keyboard rules exactly. Cursor + delta and scroll
|
||||
use the same end-of-frame reset.
|
||||
|
||||
## Cursor and mouse delta
|
||||
|
||||
Cursor position is stored as physical pixels relative to the window. The
|
||||
**delta** is the sum of the segment vectors between `set_cursor` calls
|
||||
*within the frame*, not the gross displacement from the first event. The
|
||||
first `set_cursor` after construction (or after `forget_cursor` /
|
||||
`WindowEvent::CursorLeft`) seeds the anchor without contributing to the
|
||||
delta — so the first frame the cursor enters the window never produces a
|
||||
phantom jump.
|
||||
|
||||
```text
|
||||
Frame 1: cursor enters at (100, 100) → delta = (0, 0)
|
||||
Frame 2: moves (100,100)→(105,98)→(108,95) → delta = (8, -5)
|
||||
Frame 3: no movement → delta = (0, 0)
|
||||
(cursor still at (108, 95))
|
||||
```
|
||||
|
||||
`add_mouse_delta(dx, dy)` exists for relative-motion sources that don't
|
||||
go through `CursorMoved` (a future `DeviceEvent::MouseMotion` pump, a
|
||||
pointer-lock toggle, or a synthesized test). It layers on top of the
|
||||
cursor-based delta.
|
||||
|
||||
## Scroll
|
||||
|
||||
Scroll is reported in **line-equivalent units**: wheel notches arrive as
|
||||
`LineDelta` and pass through unchanged; trackpad pixel deltas are divided
|
||||
by a fixed pixels-per-line constant (40) so a touchpad gesture and a wheel
|
||||
notch produce comparable numbers.
|
||||
|
||||
## Testing inputs directly
|
||||
|
||||
The mutator API (`press_key`, `release_mouse`, `set_cursor`,
|
||||
`add_mouse_delta`, `add_scroll`, `forget_cursor`, `release_all_held`) is
|
||||
the same path `handle_event` uses, and is intentionally public. Tests
|
||||
should call it directly rather than try to fabricate `WindowEvent`s —
|
||||
winit 0.30's `DeviceId` cannot be constructed outside a real event loop,
|
||||
so most input variants are unreachable from synthesized events. The
|
||||
mutators are unit-tested and exercised end-to-end by `stage7` integration
|
||||
tests in the [`tests`](../tests) crate.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::Space);
|
||||
assert!(input.pressed(KeyCode::Space));
|
||||
assert!(input.held(KeyCode::Space));
|
||||
|
||||
input.end_frame();
|
||||
assert!(!input.pressed(KeyCode::Space));
|
||||
assert!(input.held(KeyCode::Space));
|
||||
```
|
||||
|
||||
## Named actions and remapping
|
||||
|
||||
`InputState` answers "is `KeyCode::Space` down?". Game code shouldn't ask
|
||||
that question: physical keys are user-settings territory, and querying
|
||||
them directly couples gameplay to a fixed keyboard layout. `ActionMap`
|
||||
adds the indirection — game code asks "is `\"Jump\"` engaged?" and the
|
||||
map resolves it to whatever the user (or the program's default) has
|
||||
bound.
|
||||
|
||||
### The data model
|
||||
|
||||
An action carries two binding lists:
|
||||
|
||||
- **`defaults`** — the bindings registered from code at startup. They
|
||||
never change at runtime.
|
||||
- **`current`** — the bindings actually queried each frame. Initially a
|
||||
clone of `defaults`; remapped by the settings screen; restored by the
|
||||
"Restore defaults" button.
|
||||
|
||||
Persistence saves only `current`. On reload, the program first registers
|
||||
actions from code (defaults reappear from source), then applies the saved
|
||||
overrides on top. Actions that vanished from code never break an old
|
||||
settings file — they're silently skipped.
|
||||
|
||||
### Setting up actions
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::winit::event::MouseButton;
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
let mut actions = ActionMap::new();
|
||||
actions
|
||||
.register("Jump", [Binding::Key(KeyCode::Space)])
|
||||
.register(
|
||||
"Sprint",
|
||||
[
|
||||
Binding::Key(KeyCode::ShiftLeft),
|
||||
Binding::Key(KeyCode::ShiftRight),
|
||||
],
|
||||
)
|
||||
.register("Fire", [Binding::Mouse(MouseButton::Left)]);
|
||||
```
|
||||
|
||||
Multi-bind on either axis is supported: an action can list several
|
||||
bindings (the `Sprint` example), and one physical key can drive several
|
||||
actions (e.g. `Space` → both `"Jump"` and `"Confirm"`).
|
||||
|
||||
### Querying actions
|
||||
|
||||
```rust
|
||||
# use oxide_engine::prelude::*;
|
||||
# use oxide_engine::winit::keyboard::KeyCode;
|
||||
# let mut actions = ActionMap::new();
|
||||
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||
# let input = InputState::new();
|
||||
if actions.action_pressed("Jump", &input) {
|
||||
// Fires once, on the frame Jump becomes engaged.
|
||||
}
|
||||
if actions.action_held("Jump", &input) {
|
||||
// True every frame Jump is engaged (at least one binding held).
|
||||
}
|
||||
if actions.action_released("Jump", &input) {
|
||||
// Fires once, when the last engaged binding releases.
|
||||
}
|
||||
```
|
||||
|
||||
Action edges have **hysteresis at the action level**, not the binding
|
||||
level: pressing a second binding while the action is already engaged does
|
||||
not retrigger `action_pressed`, and releasing one binding while another
|
||||
is still held does not fire `action_released`. The edge fires only on
|
||||
the action's transition between engaged and disengaged. (See the
|
||||
[`action_pressed`](../engine/src/input/action.rs) rustdoc for the
|
||||
precise definition.)
|
||||
|
||||
Querying an unregistered action returns `false` everywhere — never a
|
||||
panic — so typo'd action names are graceful.
|
||||
|
||||
### Runtime remap
|
||||
|
||||
```rust
|
||||
# use oxide_engine::prelude::*;
|
||||
# use oxide_engine::winit::keyboard::KeyCode;
|
||||
# let mut actions = ActionMap::new();
|
||||
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||
// A bindings preferences page calls these — game code is untouched.
|
||||
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
|
||||
actions.add_binding("Jump", Binding::Key(KeyCode::Space)); // restore as alt
|
||||
actions.remove_binding("Jump", Binding::Key(KeyCode::KeyW));
|
||||
actions.clear_bindings("Jump"); // make Jump temporarily unbindable
|
||||
actions.restore_defaults("Jump"); // ↩ user's defaults
|
||||
actions.restore_all_defaults(); // ↩ everything
|
||||
```
|
||||
|
||||
### Persistence via the Stage-6 settings framework
|
||||
|
||||
`ActionOverrides` is the serializable projection of an `ActionMap`'s
|
||||
current bindings, and it derives `Default + Serialize + Deserialize` so
|
||||
it plugs straight into `Settings::register::<ActionOverrides>(name)` —
|
||||
no framework code changes needed. The whole cycle:
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
// One-time setup at startup.
|
||||
let mut actions = ActionMap::new();
|
||||
actions.register("Jump", [Binding::Key(KeyCode::Space)]);
|
||||
|
||||
let mut settings = Settings::new();
|
||||
settings.register::<ActionOverrides>("input.bindings");
|
||||
|
||||
// User remap → write into Settings → export to disk.
|
||||
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
|
||||
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
|
||||
actions.overrides();
|
||||
let on_disk = settings.export(); // RON map, persist however you like
|
||||
|
||||
// On the next launch, after re-registering defaults from code:
|
||||
settings.import(&on_disk);
|
||||
actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());
|
||||
// Jump is now bound to W again.
|
||||
```
|
||||
|
||||
The `apply_overrides` step is order-independent with respect to which
|
||||
actions the file knows about: unknown names are skipped, and registered
|
||||
actions absent from the file keep their defaults.
|
||||
|
||||
## Directional axes
|
||||
|
||||
Buttons answer "is this engaged?". Movement and camera control want a
|
||||
**direction with magnitude**. `AxisBinding` (1D, returns `f32`) and
|
||||
`Axis2DBinding` (2D, returns `Vec2`) compose direction sets of
|
||||
`Binding`s into those values. They live in the same [`ActionMap`] as
|
||||
button actions but in **separate name spaces**, so `"Move"` can be a 2D
|
||||
axis and `"MoveSlower"` a button without conflict — and a `"Move"`
|
||||
button can coexist with a `"Move"` axis if a project wants it to.
|
||||
|
||||
### 1D axes
|
||||
|
||||
An `AxisBinding` is a pair of binding sets (one for the +1 direction,
|
||||
one for −1). Any binding held on a side contributes a full unit; if
|
||||
both sides are held simultaneously they cancel to 0 — a "soft brake"
|
||||
the player gets for free.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::winit::keyboard::KeyCode;
|
||||
|
||||
let mut actions = ActionMap::new();
|
||||
actions.register_axis(
|
||||
"MoveX",
|
||||
AxisBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
),
|
||||
);
|
||||
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyD);
|
||||
assert_eq!(actions.axis("MoveX", &input), 1.0);
|
||||
```
|
||||
|
||||
Multiple bindings on the same direction do **not** stack (`D` and `→`
|
||||
both held still reads as `1.0`, not `2.0`) — the axis reports
|
||||
direction, not accumulated input.
|
||||
|
||||
### 2D axes
|
||||
|
||||
`Axis2DBinding::new(right, left, up, down)` composes four direction
|
||||
sets into a `Vec2`. Diagonals are intentionally **not normalized** — a
|
||||
game that wants unit-length movement normalizes at the call site; a
|
||||
game that wants diagonal-faster gets it for free. The cancel-on-both
|
||||
rule applies independently on each axis.
|
||||
|
||||
```rust
|
||||
# use oxide_engine::prelude::*;
|
||||
# use oxide_engine::winit::keyboard::KeyCode;
|
||||
let mut actions = ActionMap::new();
|
||||
actions.register_axis_2d(
|
||||
"Move",
|
||||
Axis2DBinding::new(
|
||||
[Binding::Key(KeyCode::KeyD)],
|
||||
[Binding::Key(KeyCode::KeyA)],
|
||||
[Binding::Key(KeyCode::KeyW)],
|
||||
[Binding::Key(KeyCode::KeyS)],
|
||||
),
|
||||
);
|
||||
|
||||
let mut input = InputState::new();
|
||||
input.press_key(KeyCode::KeyW);
|
||||
input.press_key(KeyCode::KeyA);
|
||||
let v = actions.axis_2d("Move", &input);
|
||||
assert_eq!(v, oxide_engine::math::Vec2::new(-1.0, 1.0));
|
||||
// If you want unit-length: `if v != Vec2::ZERO { v.normalize() } else { v }`
|
||||
```
|
||||
|
||||
### Remap, restore, and persistence
|
||||
|
||||
`set_axis_bindings` / `set_axis_2d_bindings` swap the current bindings
|
||||
without renaming the action. `restore_axis_defaults` /
|
||||
`restore_axis_2d_defaults` revert to the code-defined bindings.
|
||||
`restore_all_defaults` covers every action across all three kinds in
|
||||
one call.
|
||||
|
||||
[`ActionOverrides`] carries axis overrides alongside button overrides
|
||||
in three sub-maps. The settings round-trip is identical to the button
|
||||
case — `ActionOverrides` is the same settings-section type:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::prelude::*;
|
||||
# let mut actions = ActionMap::new();
|
||||
# let mut settings = Settings::new();
|
||||
settings.register::<ActionOverrides>("input.bindings");
|
||||
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
|
||||
actions.overrides();
|
||||
let on_disk = settings.export(); // axes, axes_2d, and buttons all persist
|
||||
```
|
||||
|
||||
Older settings files written before axes existed (i.e. with no `axes` or
|
||||
`axes_2d` field in the RON) load cleanly — the missing sub-maps
|
||||
deserialize as empty, and registered axes keep their code-defined
|
||||
defaults.
|
||||
|
||||
## Status and what's next
|
||||
|
||||
- **Piece 1 (✅).** Raw `InputState` + runner integration.
|
||||
- **Piece 2 (✅).** Named button action mapping with defaults, multi-bind
|
||||
in either direction, runtime remap, and RON persistence through the
|
||||
Stage-6 settings framework.
|
||||
- **Piece 3 (✅ — this section).** 1D and 2D directional axes composed
|
||||
from `Binding` direction sets, sharing `ActionMap` storage and the
|
||||
same `ActionOverrides` persistence payload.
|
||||
- **Editor pieces (planned).** Flythrough camera using the action map,
|
||||
bindings page in Preferences contributed via the Stage-6 extension API,
|
||||
transform gizmos.
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
# Layers, Groups & Tags
|
||||
|
||||
`oxide_engine::layer` is the engine's shared answer to *"which things interact
|
||||
with which?"*. Instead of every subsystem inventing its own notion of
|
||||
collision groups, render masks, or query filters, they all reference one
|
||||
primitive — the [`LayerMask`] — so a layer named once is honored everywhere.
|
||||
|
||||
This system lands in Stage 5 (Engine Core Framework). The consumers below are
|
||||
wired up in their own stages, but they all build on the types here.
|
||||
|
||||
## The model: one Layer, many Groups
|
||||
|
||||
Oxide follows the **Unity model**, separating two concepts:
|
||||
|
||||
- **[`Layer`]** — *single-valued* membership. Every entity is on exactly **one**
|
||||
of 32 logical layers (its index `0..32`). This is the fast filter slot used by
|
||||
rendering, physics, and queries.
|
||||
- **[`Tags`]** — *multi-valued* gameplay grouping. An entity can be in **any
|
||||
number** of named groups (`"Enemies"`, `"Interactables"`, `"SaveOnExit"`) that
|
||||
game code and scripts look up by name.
|
||||
|
||||
So: *what kind of thing is this, for fast filtering?* → one **Layer**. *What
|
||||
gameplay categories does it belong to?* → many **Groups** (tags). The two never
|
||||
fight over the same slot.
|
||||
|
||||
## `LayerMask`: the filter primitive
|
||||
|
||||
A `LayerMask` is a 32-slot bitset packed into a `u32`. An entity's `Layer` is a
|
||||
single index, but the things that *select* entities carry a **mask** — a
|
||||
camera's visibility, a physics collision filter, a raycast's query mask — so a
|
||||
filter can target several layers at once. An entity is selected when its layer
|
||||
is one of the bits in the filter:
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::{Layer, LayerMask};
|
||||
|
||||
let entity = Layer::on(2); // membership: layer 2
|
||||
let filter = LayerMask::NONE.with(1).with(2); // a filter selecting 1 or 2
|
||||
assert!(entity.matches(filter)); // layer 2 is in the mask → selected
|
||||
```
|
||||
|
||||
`a.intersects(b)` (equivalently `(a & b) != 0`) is the universal mask
|
||||
interaction test. Everything else (`with`/`without`/`union`/`intersection`/
|
||||
`complement`, the `|`/`&`/`^`/`!` operators, `iter`) is convenience around that.
|
||||
|
||||
Layer indices run `0..32`. Passing an index `>= 32` panics in every build — a
|
||||
mistake worth catching loudly rather than wrapping silently.
|
||||
|
||||
`Layer` is **node-baked**: `Scene::spawn` auto-attaches `Layer::DEFAULT` (index
|
||||
0) to every entity, so a fresh entity is visible to broad "see everything"
|
||||
filters out of the box.
|
||||
|
||||
## Naming layers: `LayerRegistry`
|
||||
|
||||
Bits are not self-documenting, so a project keeps a [`LayerRegistry`] mapping
|
||||
indices to names. Index 0 is seeded as `"Default"`; the rest are unnamed until
|
||||
assigned. The registry is project-level data (serialized with the project in a
|
||||
later stage), and renaming a layer never moves an entity — only the label
|
||||
changes.
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::LayerRegistry;
|
||||
|
||||
let mut registry = LayerRegistry::new();
|
||||
registry.set(1, "Player");
|
||||
registry.set(2, "NPC");
|
||||
|
||||
// Author a filter by name instead of by magic number:
|
||||
let visible_to_camera = registry.mask_of(["Player", "NPC"]);
|
||||
assert_eq!(registry.index_of("Player"), Some(1));
|
||||
```
|
||||
|
||||
The editor seeds a small, generally-useful starter set — `Default`, `UI`,
|
||||
`Player`, `World` — and exposes the registry through its **Layer Names** editor
|
||||
(opened from the inspector's `Layer` dropdown). Because both a camera's
|
||||
visibility mask and a raycast's filter can be built from the same names, the
|
||||
*named layer* is the single source of truth.
|
||||
|
||||
## Gameplay grouping: `Tags` + `GroupRegistry`
|
||||
|
||||
[`Tags`] is the per-entity, string-keyed set that holds an entity's **group**
|
||||
membership — the multi-valued counterpart to its single `Layer`:
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::Tags;
|
||||
|
||||
let mut tags = Tags::single("Enemy");
|
||||
tags.insert("Flying");
|
||||
assert!(tags.contains("Enemy"));
|
||||
```
|
||||
|
||||
The set of *valid* group names is project-level data in a [`GroupRegistry`], so
|
||||
the editor offers a fixed vocabulary to pick from (predefined, like layers)
|
||||
rather than free-typed strings. Defining or deleting a group only changes that
|
||||
vocabulary — it never touches the tags already on entities:
|
||||
|
||||
```rust
|
||||
use oxide_engine::layer::GroupRegistry;
|
||||
|
||||
let mut groups = GroupRegistry::new();
|
||||
groups.define("Enemies");
|
||||
groups.define("Interactables");
|
||||
assert!(groups.contains("Enemies"));
|
||||
```
|
||||
|
||||
In the editor, the inspector's **Groups** dropdown is a multi-select of the
|
||||
defined groups (each a checkbox toggling membership in the entity's `Tags`), with
|
||||
an **Edit groups…** entry opening the Groups editor to manage the vocabulary.
|
||||
|
||||
Use a **layer** when something must filter quickly and en masse (rendering,
|
||||
physics, queries). Use a **group** when you need to ask "what gameplay
|
||||
categories is this in?" and an entity may be in several at once.
|
||||
|
||||
## Attaching to entities
|
||||
|
||||
`Layer` is auto-attached; `Tags` is an ordinary ECS component — attach it through
|
||||
the scene's world and query it like anything else:
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::layer::{LayerMask, Layer, Tags};
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let guard = scene.spawn("guard", Transform::IDENTITY); // Layer::DEFAULT auto-attached
|
||||
scene.world_mut().insert_one(guard, Layer::on(2)).unwrap(); // move to layer 2
|
||||
scene.world_mut().insert_one(guard, Tags::single("Enemy")).unwrap();
|
||||
|
||||
// Find everything a layer-2 query would hit:
|
||||
let filter = LayerMask::layer(2);
|
||||
for (entity, layer) in scene.world().query::<&Layer>().iter() {
|
||||
if layer.matches(filter) {
|
||||
// ... this entity is selected
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All five types (`LayerMask`, `LayerRegistry`, `Layer`, `Tags`, `GroupRegistry`)
|
||||
are `serde`-serializable, so layer/group data round-trips through RON and is
|
||||
dual-editable from the editor and from scripts/AI agents like every other engine
|
||||
component.
|
||||
|
||||
## Who consumes this
|
||||
|
||||
| Consumer | Stage | How it uses layers |
|
||||
|----------|-------|--------------------|
|
||||
| Render pass pipeline | 5 | A camera holds a visibility `LayerMask`; only entities whose `Layer` is in it are drawn |
|
||||
| Physics | 9 | A collider's membership + filter masks drive collision groups and sensor/trigger filtering |
|
||||
| Scene queries | 9 | A raycast/shape-cast carries a filter mask tested against candidates' layer |
|
||||
| Editor | 6+ | Single-select `Layer` dropdown + Layer Names editor; multi-select `Groups` dropdown + Groups editor |
|
||||
|
||||
[`LayerMask`]: ../engine/src/layer/mask.rs
|
||||
[`LayerRegistry`]: ../engine/src/layer/registry.rs
|
||||
[`Layer`]: ../engine/src/layer/components.rs
|
||||
[`Tags`]: ../engine/src/layer/components.rs
|
||||
[`GroupRegistry`]: ../engine/src/layer/groups.rs
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
# Math & Core Primitives
|
||||
|
||||
The `oxide_engine::math` module is the foundation every other system depends on.
|
||||
It builds on [`glam`](https://docs.rs/glam) for vectors, quaternions, and
|
||||
matrices, and adds the engine's higher-level geometric and utility types.
|
||||
|
||||
This document is the usage reference for the module as delivered in **Stage 1**.
|
||||
For the conventions these types follow (handedness, units, color space), see
|
||||
[conventions.md](conventions.md).
|
||||
|
||||
## Importing
|
||||
|
||||
Everything is available through the module path or the prelude:
|
||||
|
||||
```rust
|
||||
use oxide_engine::math::{Transform, Aabb, Ray, Plane, Frustum, Color, Rect, Range3};
|
||||
// or, more commonly:
|
||||
use oxide_engine::prelude::*;
|
||||
```
|
||||
|
||||
The prelude also re-exports the `glam` types you need for everyday work — `Vec2`,
|
||||
`Vec3`, `Vec4`, `Quat`, `Mat3`, `Mat4`, and `EulerRot` — so downstream crates
|
||||
need no direct `glam` dependency.
|
||||
|
||||
## Contents
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| [`Transform`](#transform) | Placement: translation + rotation + scale |
|
||||
| [`Aabb`](#aabb) | Axis-aligned bounding box (geometry/bounds/culling) |
|
||||
| [`Ray`](#ray) | Origin + normalized direction (picking, queries) |
|
||||
| [`Plane`](#plane) | Infinite plane in Hessian normal form |
|
||||
| [`Frustum`](#frustum) | Six-plane view volume for visibility culling |
|
||||
| [`Color`](#color) | Linear RGBA color with sRGB conversion |
|
||||
| [`Rect`](#rect) | 2D rectangle (UI, viewports, texture regions) |
|
||||
| [`Range3`](#range3) | 3D value range (clamp, lerp, remap) |
|
||||
|
||||
All types are `Copy`, `PartialEq`, and `serde`-(de)serializable.
|
||||
|
||||
---
|
||||
|
||||
## Transform
|
||||
|
||||
A 3D affine transform stored in **decomposed** form — `translation` (`Vec3`),
|
||||
`rotation` (`Quat`), and `scale` (`Vec3`) — so each channel stays editable
|
||||
without matrix round-trips. The effective matrix is `T * R * S`.
|
||||
|
||||
### Construction
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let a = Transform::IDENTITY;
|
||||
let b = Transform::from_translation(Vec3::new(0.0, 1.0, 0.0));
|
||||
let c = Transform::from_rotation(Quat::from_rotation_y(90_f32.to_radians()));
|
||||
let d = Transform::from_scale(Vec3::splat(2.0));
|
||||
let e = Transform::from_trs(
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
Quat::from_euler(EulerRot::XYZ, 0.1, 0.2, 0.3),
|
||||
Vec3::splat(1.0),
|
||||
);
|
||||
|
||||
// From / to a 4x4 matrix:
|
||||
let m = e.to_matrix(); // glam::Mat4
|
||||
let back = Transform::from_matrix(m);
|
||||
let affine = e.to_affine(); // glam::Affine3A (cheaper to compose)
|
||||
```
|
||||
|
||||
### Composition and hierarchy
|
||||
|
||||
`mul_transform` composes parent-first, so this is how you resolve a child into
|
||||
its parent's space:
|
||||
|
||||
```rust
|
||||
let parent = Transform::from_trs(
|
||||
Vec3::new(10.0, 0.0, 0.0),
|
||||
Quat::from_rotation_y(90_f32.to_radians()),
|
||||
Vec3::splat(2.0),
|
||||
);
|
||||
let child_local = Transform::from_translation(Vec3::new(0.0, 0.0, 1.0));
|
||||
let child_world = parent.mul_transform(&child_local);
|
||||
// child_world.translation == (12, 0, 0)
|
||||
```
|
||||
|
||||
Composition is exact for uniform scale and a closest-fit approximation for
|
||||
non-uniform scale combined with rotation (see
|
||||
[conventions](conventions.md#transform-composition)).
|
||||
|
||||
### Applying a transform
|
||||
|
||||
```rust
|
||||
let t = Transform::from_trs(
|
||||
Vec3::new(1.0, 0.0, 0.0),
|
||||
Quat::from_rotation_z(90_f32.to_radians()),
|
||||
Vec3::splat(2.0),
|
||||
);
|
||||
|
||||
let p = t.transform_point(Vec3::new(1.0, 0.0, 0.0)); // affected by T, R, S
|
||||
let v = t.transform_vector(Vec3::new(1.0, 0.0, 0.0)); // R and S only (no translation)
|
||||
```
|
||||
|
||||
### Inverse
|
||||
|
||||
`inverse()` returns a transform that undoes this one. It is exact for uniform
|
||||
scale; with any zero scale component the transform is not invertible and the
|
||||
inverse scale will contain infinities (check with `is_finite()`).
|
||||
|
||||
```rust
|
||||
let undo = t.inverse();
|
||||
let identity = t.mul_transform(&undo); // ≈ Transform::IDENTITY
|
||||
```
|
||||
|
||||
### Direction and orientation helpers
|
||||
|
||||
```rust
|
||||
let dir_forward = t.forward(); // local -Z
|
||||
let dir_up = t.up(); // local +Y
|
||||
let dir_right = t.right(); // local +X
|
||||
|
||||
// Build an orientation that looks from `eye` toward `target`:
|
||||
let cam = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
// cam.forward() points at the target. Degenerate (eye == target) → identity rotation.
|
||||
```
|
||||
|
||||
### Edge cases handled
|
||||
|
||||
- **Zero scale** → non-invertible; the forward transform is still finite.
|
||||
- **Gimbal-lock orientations** (e.g. ±90° pitch) round-trip through a matrix
|
||||
without losing orthonormality of the basis vectors.
|
||||
- **Degenerate `looking_at`** (eye == target) returns identity rotation rather
|
||||
than producing NaNs.
|
||||
|
||||
---
|
||||
|
||||
## Aabb
|
||||
|
||||
An axis-aligned bounding box defined by `min` and `max` corners. Used for bounds,
|
||||
broad-phase overlap, and frustum culling. A box is *empty* when any `min`
|
||||
component exceeds its `max`; `Aabb::EMPTY` is the identity for `union`.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let a = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0)); // corners auto-sorted
|
||||
let b = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
|
||||
let c = Aabb::from_points([Vec3::ZERO, Vec3::new(2.0, -1.0, 4.0), Vec3::new(-3.0, 5.0, 1.0)]);
|
||||
|
||||
// Queries:
|
||||
let center = b.center();
|
||||
let size = b.size();
|
||||
let inside = b.contains_point(Vec3::ZERO);
|
||||
let near = b.closest_point(Vec3::new(5.0, 0.0, 0.0));
|
||||
|
||||
// Set operations:
|
||||
let u = a.union(&b);
|
||||
let i = a.intersection(&b); // Aabb::EMPTY if disjoint
|
||||
let hit = a.intersects(&b); // bool (touching counts)
|
||||
|
||||
// Acceleration-structure metrics:
|
||||
let area = b.surface_area(); // for SAH
|
||||
let vol = b.volume();
|
||||
let pts = b.corners(); // [Vec3; 8]
|
||||
|
||||
// Ray test (slab method): returns entry distance t, or None.
|
||||
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
|
||||
if let Some(t) = b.ray_intersection(&ray) {
|
||||
let point = ray.at(t);
|
||||
}
|
||||
```
|
||||
|
||||
A ray whose origin is inside the box returns `Some(0.0)`.
|
||||
|
||||
---
|
||||
|
||||
## Ray
|
||||
|
||||
A half-line with an `origin` and a **normalized** `direction`. Because the
|
||||
direction is unit-length, the parameter `t` in `at(t)` is a true distance.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let r = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0)); // direction normalized to +Y
|
||||
let r2 = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
|
||||
|
||||
let p = r.at(4.0); // origin + direction * 4
|
||||
let valid = r.is_valid(); // false if direction was zero-length
|
||||
let near = r.closest_point(target); // clamped to t >= 0
|
||||
let dist = r.distance_to_point(target);
|
||||
```
|
||||
|
||||
If you pass a zero-length direction, the ray is left degenerate; check
|
||||
`is_valid()` before relying on it.
|
||||
|
||||
---
|
||||
|
||||
## Plane
|
||||
|
||||
An infinite plane in Hessian normal form: a unit `normal` and a signed distance
|
||||
`d`, such that every point on the plane satisfies `normal·p + d = 0`. The
|
||||
positive half-space is the side the normal points toward.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let p1 = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
|
||||
let p2 = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y); // normal via right-hand rule → +Z
|
||||
let p3 = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0); // normalized on construction
|
||||
|
||||
let sd = p1.signed_distance(Vec3::new(0.0, 5.0, 0.0)); // +3.0 (in front)
|
||||
let proj = p1.project_point(Vec3::new(3.0, 7.0, -2.0)); // orthogonal projection onto plane
|
||||
let flipped = p1.flipped(); // same plane, reversed normal
|
||||
|
||||
// Ray test: None if parallel or pointing away.
|
||||
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
|
||||
if let Some(t) = p1.ray_intersection(&ray) {
|
||||
let hit = ray.at(t);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frustum
|
||||
|
||||
A view volume represented by six planes (left, right, bottom, top, near, far),
|
||||
each with its normal pointing **inward**. A point is inside when it lies in the
|
||||
positive half-space of every plane. Built from a combined view-projection matrix
|
||||
via the Gribb–Hartmann method; works for perspective and orthographic
|
||||
projections alike.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0);
|
||||
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let frustum = Frustum::from_view_projection(proj * view);
|
||||
|
||||
let visible_point = frustum.contains_point(Vec3::ZERO);
|
||||
|
||||
let bb = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
|
||||
let visible_box = frustum.intersects_aabb(&bb);
|
||||
|
||||
let visible_sphere = frustum.intersects_sphere(Vec3::ZERO, 1.0);
|
||||
```
|
||||
|
||||
`intersects_aabb` is **conservative**: it never culls a box that is actually
|
||||
visible, though it may very rarely keep one that is just outside a corner. That
|
||||
is the correct trade-off for rendering, where false positives cost a wasted draw
|
||||
but false negatives cause visible pop-out.
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
Linear RGBA color with `f32` channels. The engine works in **linear** space;
|
||||
conversions to/from 8-bit sRGB are explicit. Values may exceed `1.0` to represent
|
||||
HDR/emissive intensity and are not clamped in storage.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let white = Color::WHITE;
|
||||
let custom = Color::rgba(0.2, 0.4, 0.6, 1.0);
|
||||
let from_pdf = Color::from_srgb_u8(135, 206, 235); // sRGB bytes → linear
|
||||
let from_hex = Color::from_hex(0x87CEEB); // #87CEEB → linear
|
||||
|
||||
let bytes = custom.to_srgb_u8(); // [u8; 4] sRGB, clamped to [0,1]
|
||||
let v4 = custom.to_vec4(); // Vec4 [r,g,b,a]
|
||||
let v3 = custom.to_vec3(); // Vec3 rgb
|
||||
let faded = custom.with_alpha(0.5);
|
||||
let mid = Color::BLACK.lerp(Color::WHITE, 0.5); // t clamped to [0,1]
|
||||
```
|
||||
|
||||
Constants: `BLACK`, `WHITE`, `RED`, `GREEN`, `BLUE`, `TRANSPARENT`.
|
||||
|
||||
---
|
||||
|
||||
## Rect
|
||||
|
||||
A 2D axis-aligned rectangle for UI, viewports, and texture regions.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let r1 = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0)); // corners auto-sorted
|
||||
let r2 = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
|
||||
let r3 = Rect::from_center_size(Vec2::ZERO, Vec2::splat(2.0));
|
||||
|
||||
let (w, h) = (r2.width(), r2.height());
|
||||
let size = r2.size();
|
||||
let area = r2.area();
|
||||
let c = r2.center();
|
||||
let empty = Rect::ZERO.is_empty();
|
||||
|
||||
let inside = r2.contains_point(Vec2::ONE);
|
||||
let hit = r2.intersects(&r3);
|
||||
let i = r2.intersection(&r3); // Rect::ZERO if disjoint
|
||||
let u = r2.union(&r3);
|
||||
let near = r2.closest_point(Vec2::new(5.0, -1.0));
|
||||
let grown = r2.expanded(1.0); // negative shrinks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Range3
|
||||
|
||||
A 3D **value** range — an inclusive `[min, max]` interval per axis. Unlike
|
||||
`Aabb` (which models geometry), `Range3` models a *value range* for clamping,
|
||||
interpolation, and remapping. It deliberately provides `lerp`/`inverse_lerp`/
|
||||
`remap`, which an `Aabb` does not.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let r = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
|
||||
let unit = Range3::UNIT; // [0, 1] per axis
|
||||
let sym = Range3::symmetric(Vec3::splat(2.0)); // [-2, 2] per axis
|
||||
|
||||
let span = r.span();
|
||||
let center = r.center();
|
||||
let clamped = r.clamp(Vec3::new(-5.0, 50.0, 200.0)); // → (0, 50, 100)
|
||||
let inside = r.contains(Vec3::splat(50.0));
|
||||
|
||||
let v = r.lerp(Vec3::splat(0.25)); // NOT clamped — extrapolates outside [0,1]
|
||||
let t = r.inverse_lerp(Vec3::splat(25.0)); // → 0.25 per axis (0.0 on zero-span axes)
|
||||
let remapped = r.remap(Vec3::splat(50.0), &unit); // 50 in [0,100] → 0.5 in [0,1]
|
||||
```
|
||||
|
||||
`inverse_lerp` guards against division by zero: an axis with zero span yields
|
||||
`0.0` rather than `NaN`/`inf`.
|
||||
|
||||
---
|
||||
|
||||
## Testing and performance
|
||||
|
||||
- **Unit tests** for every type live in that type's source file
|
||||
(`engine/src/math/*.rs`), covering core behavior and edge cases (zero scale,
|
||||
gimbal lock, degenerate rays/planes, empty boxes, zero-span ranges).
|
||||
- **Integration / fuzz tests** live in the `oxide-tests` crate
|
||||
(`stage1::fuzz_transform_chains_stay_stable` builds long random transform
|
||||
chains and verifies stability and inverse round-trips).
|
||||
- **Benchmark:** `cargo bench -p oxide-engine` runs the `transform` benchmark.
|
||||
The Stage 1 budget — 1,000,000 transform compositions in under 10 ms — is met
|
||||
with margin (~5 ms on a typical desktop).
|
||||
|
||||
## Runnable example
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin math_demo
|
||||
```
|
||||
|
||||
`examples/src/bin/math_demo.rs` exercises every type above and prints the
|
||||
results — a good place to see the API in use end to end.
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# App, Modules & Scheduling
|
||||
|
||||
`oxide_engine::app` is where the Stage-5 core framework comes together. An
|
||||
[`App`] owns the shared engine state and a [`Schedule`] of systems; functionality
|
||||
is added by **modules**. This is the spine the rest of the engine plugs into: the
|
||||
engine is *composed* rather than hard-wired, and an exported game compiles in
|
||||
only the modules it registers.
|
||||
|
||||
## The App
|
||||
|
||||
An `App` owns:
|
||||
|
||||
- the active [`Scene`](scene.md),
|
||||
- the shared [`AssetServer`](assets.md),
|
||||
- the [`TypeRegistry`](reflection.md) (dual-editable components),
|
||||
- the project's [`LayerRegistry`](layers.md),
|
||||
- frame [`Time`], and
|
||||
- arbitrary user **resources** (a type-keyed store).
|
||||
|
||||
```rust
|
||||
use oxide_engine::app::{App, DefaultModules};
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
app.update(1.0 / 60.0); // advance one frame
|
||||
```
|
||||
|
||||
> Note: the application core is `oxide_engine::app::App`. It is intentionally
|
||||
> *not* in the prelude, to avoid clashing with the windowing
|
||||
> [`App`](windowing.md) trait (the per-window event handler). Import it directly.
|
||||
|
||||
### Resources
|
||||
|
||||
Resources are shared singletons addressed by type — the home for state that
|
||||
isn't per-entity (an input map, a physics world, game settings):
|
||||
|
||||
```rust
|
||||
# use oxide_engine::app::App;
|
||||
# let mut app = App::new();
|
||||
app.insert_resource(0u32);
|
||||
*app.get_resource_mut::<u32>().unwrap() += 1;
|
||||
assert_eq!(app.get_resource::<u32>(), Some(&1));
|
||||
```
|
||||
|
||||
## Systems and the schedule
|
||||
|
||||
A **system** is any `FnMut(&mut App)` attached to a [`Schedule`] phase. Phases run
|
||||
in a fixed order each frame; within a phase, systems run in registration order,
|
||||
so behavior is fully deterministic.
|
||||
|
||||
| Phase | Purpose |
|
||||
|-------|---------|
|
||||
| `First` | start-of-frame bookkeeping |
|
||||
| `Input` | gather input (Stage 7) |
|
||||
| `PreUpdate` | engine work before game logic |
|
||||
| `FixedUpdate` | fixed-timestep work; runs **0..n** times per frame (physics, Stage 9) |
|
||||
| `Update` | per-frame game logic |
|
||||
| `PostUpdate` | engine work after game logic |
|
||||
| `Render` | drawing (Stage 5 pipeline onward) |
|
||||
| `Last` | end-of-frame cleanup |
|
||||
|
||||
```rust
|
||||
use oxide_engine::app::{App, Schedule};
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let mut app = App::new();
|
||||
app.scene.spawn("spinner", Transform::IDENTITY);
|
||||
app.add_system(Schedule::Update, |app| {
|
||||
let dt = app.time.delta;
|
||||
for e in app.scene.entities().collect::<Vec<_>>() {
|
||||
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
|
||||
t.translation.x += dt;
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Systems get exclusive `&mut App` while running (the schedule is moved out of the
|
||||
app for the duration), so a system can freely read and mutate the scene,
|
||||
resources, and assets.
|
||||
|
||||
### Fixed timestep
|
||||
|
||||
`FixedUpdate` is driven by an accumulator so simulation is frame-rate
|
||||
independent: each `update(dt)` runs as many whole `fixed_delta` steps as the
|
||||
accumulated time allows. The number of steps per frame is **capped** so a long
|
||||
stall (a breakpoint, a hitch) cannot trigger an unbounded catch-up "spiral of
|
||||
death". Set the rate with `app.set_fixed_timestep(seconds)` (default 1/60).
|
||||
|
||||
The whole frame's scheduling overhead is a few dozen nanoseconds even with
|
||||
several systems registered (see `cargo bench -p oxide-engine --bench app`), so it
|
||||
is lost in the noise next to any real per-frame work.
|
||||
|
||||
## Modules
|
||||
|
||||
A [`Module`] is the unit of engine extension. Its [`build`](Module::build) method
|
||||
registers systems, component types, asset loaders, and resources through the
|
||||
`App` facade. Everything registered during `build` is **attributed to the
|
||||
module**, so it can be enabled, disabled, or removed as one unit.
|
||||
|
||||
```rust
|
||||
use oxide_engine::app::{App, Module, Schedule};
|
||||
|
||||
struct HeartbeatModule;
|
||||
impl Module for HeartbeatModule {
|
||||
fn name(&self) -> &'static str { "heartbeat" }
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(0u64);
|
||||
app.add_system(Schedule::Update, |app| {
|
||||
*app.get_resource_mut::<u64>().unwrap() += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_module(HeartbeatModule);
|
||||
```
|
||||
|
||||
### Enable / disable / remove
|
||||
|
||||
These power the editor's module management (Stage 6) and the "ship only what you
|
||||
use" principle:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::app::{App, Module, Schedule};
|
||||
# struct HeartbeatModule;
|
||||
# impl Module for HeartbeatModule {
|
||||
# fn name(&self) -> &'static str { "heartbeat" }
|
||||
# fn build(&self, app: &mut App) {}
|
||||
# }
|
||||
# let mut app = App::new();
|
||||
# app.add_module(HeartbeatModule);
|
||||
app.set_module_enabled("heartbeat", false); // systems skipped, nothing removed
|
||||
app.set_module_enabled("heartbeat", true); // resumes
|
||||
app.remove_module("heartbeat"); // systems, types, loaders, resources gone
|
||||
```
|
||||
|
||||
`remove_module` undoes every registration the module made — its systems,
|
||||
reflected types, asset loaders, and resources — leaving no dangling references.
|
||||
Disabling is the cheap, reversible version (systems are skipped but kept).
|
||||
|
||||
### Built-in modules
|
||||
|
||||
`DefaultModules` bundles the engine's standard set:
|
||||
|
||||
- **`core`** — registers the always-present scene component types (`Transform`,
|
||||
`Node`, `Layer`, `Tags`) for reflection, exposing them to the editor and
|
||||
scripts.
|
||||
- **`render`** — registers the renderable `MeshRenderer` component and (as the
|
||||
data-driven render pipeline grows) the render-phase systems.
|
||||
|
||||
Subsystems from Stage 9 on (physics, audio, …) are built as their own
|
||||
feature-gated crates, each exposing a `Module`, so a project pays for them only
|
||||
by registering them.
|
||||
|
||||
[`App`]: ../engine/src/app/mod.rs
|
||||
[`Time`]: ../engine/src/app/mod.rs
|
||||
[`Schedule`]: ../engine/src/app/schedule.rs
|
||||
[`Module`]: ../engine/src/app/module.rs
|
||||
[`Scene`]: ../engine/src/scene/graph.rs
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
# Physics (`oxide-physics`) — Stage 9
|
||||
|
||||
Oxide's physics is a **feature-gated module** (`oxide-physics`) built on
|
||||
[`rapier3d`](https://rapier.rs), added to an app through the Stage-5 module
|
||||
system. It is a comprehensive rigid-body system — collision, scene queries,
|
||||
joints, and a kinematic character controller — not a thin wrapper. This document
|
||||
grows piece by piece as Stage 9 lands; it currently covers the **data model and
|
||||
module wiring** (piece 1).
|
||||
|
||||
## Design: the ECS is the source of truth
|
||||
|
||||
A physics object is described by two plain, serializable, reflected components on
|
||||
a scene entity:
|
||||
|
||||
- [`RigidBody`](#rigidbody) — *how* it moves (or that it doesn't).
|
||||
- [`Collider`](#collider) — *what shape* it is, its material, and the
|
||||
[`LayerMask`](layers.md) filtering of what it collides with.
|
||||
|
||||
The rapier simulation world is a **transient resource rebuilt from these
|
||||
components**, never the authoritative store. That has a deliberate payoff: the
|
||||
Stage-8.7 [play-mode snapshot](play-mode.md) captures these components like any
|
||||
other, so **Play** runs the simulation, **Stop** reverts the authored components,
|
||||
and the next **Play** rebuilds the rapier world fresh — with no special-casing.
|
||||
The [`Transform`](scene.md) is the authoritative pose; the simulation writes it
|
||||
back each fixed step (a later piece).
|
||||
|
||||
## Adding physics to an app
|
||||
|
||||
```rust
|
||||
use oxide_engine::app::{App, DefaultModules};
|
||||
use oxide_physics::PhysicsModule;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
app.add_module(PhysicsModule); // registers RigidBody/Collider + PhysicsSettings
|
||||
```
|
||||
|
||||
`PhysicsModule` registers the component types for reflection (so they are
|
||||
dual-editable from the inspector and from scripts/RON, and captured by the play
|
||||
snapshot) and inserts a [`PhysicsSettings`] resource holding the global gravity
|
||||
vector. Like every module it can be enabled, disabled, or removed as a unit, so a
|
||||
game that never uses physics never compiles it in.
|
||||
|
||||
## RigidBody
|
||||
|
||||
The dynamics half of a physics object — attach alongside a `Collider`.
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `kind` | `Dynamic` (simulated), `Kinematic` (game-moved, unaffected by forces), or `Static` (immovable world geometry) |
|
||||
| `mass` | Mass in kg; `0` derives it from the collider's `density` |
|
||||
| `linear_damping` / `angular_damping` | Velocity drag (`0` = none) |
|
||||
| `gravity_scale` | Per-body gravity multiplier (`1` normal, `0` floats) |
|
||||
| `ccd` | Continuous collision detection for fast bodies (off by default) |
|
||||
|
||||
```rust
|
||||
use oxide_physics::{RigidBody, RigidBodyKind};
|
||||
|
||||
let dynamic = RigidBody::default(); // a fully-simulated body
|
||||
let floor = RigidBody::static_body(); // immovable
|
||||
let platform = RigidBody::kinematic(); // moved by the game
|
||||
```
|
||||
|
||||
## Collider
|
||||
|
||||
The shape + material half — attach on its own for static geometry, or with a
|
||||
`RigidBody` for a moving body. The shape is a flat selector plus dimension fields
|
||||
(mirroring `MeshRenderer`'s `PrimitiveShape`), so the inspector renders a clean
|
||||
combo + drag-values:
|
||||
|
||||
| `shape` | Dimensions used |
|
||||
|---------|-----------------|
|
||||
| `Box` | `half_extents` (per-axis half sizes) |
|
||||
| `Sphere` | `radius` |
|
||||
| `Capsule` | `radius` + `half_height` (axis = local `+Y`) |
|
||||
| `Cylinder` | `radius` + `half_height` (axis = local `+Y`) |
|
||||
|
||||
Material/filter fields: `friction`, `restitution`, `density`, `sensor` (a trigger
|
||||
that reports overlap without resolving contact), and the `membership` / `filter`
|
||||
[`LayerMask`](layers.md)s. Two colliders interact only when each one's
|
||||
`membership` intersects the other's `filter`, so collision groups, triggers, and
|
||||
(later) scene queries all use the engine's one shared filtering primitive.
|
||||
|
||||
```rust
|
||||
use oxide_engine::math::Vec3;
|
||||
use oxide_engine::layer::LayerMask;
|
||||
use oxide_physics::Collider;
|
||||
|
||||
let ground = Collider::cuboid(Vec3::new(10.0, 0.5, 10.0));
|
||||
let ball = Collider::ball(0.5);
|
||||
let trigger = Collider::cuboid(Vec3::splat(1.0)).as_sensor()
|
||||
.with_layers(LayerMask::layer(0), LayerMask::layer(1)); // only fires for layer 1
|
||||
```
|
||||
|
||||
Convex-hull and triangle-mesh colliders (which need mesh data) are a later
|
||||
Stage-9 piece.
|
||||
|
||||
## Simulation
|
||||
|
||||
`PhysicsModule` installs a [`PhysicsWorld`] resource — the rapier simulation plus
|
||||
an entity ↔ body map — and a `FixedUpdate` system that, each fixed step:
|
||||
|
||||
1. **syncs** the rapier world to the scene (inserts a body+collider for each new
|
||||
physics entity, removes bodies for despawned ones, pushes kinematic targets),
|
||||
2. **steps** rapier by one [`fixed_delta`](modules.md), then
|
||||
3. **writes back** each moved body's pose onto its entity's `Transform`.
|
||||
|
||||
So physics runs whenever the app advances its fixed timestep — including the
|
||||
editor's **Play** mode, which is the first real consumer (Play drops a body,
|
||||
Stop reverts it). Nothing extra is wired: the play snapshot already captures the
|
||||
components.
|
||||
|
||||
```rust
|
||||
# use oxide_engine::app::{App, DefaultModules};
|
||||
# use oxide_engine::math::{Transform, Vec3};
|
||||
# use oxide_physics::{PhysicsModule, RigidBody, Collider};
|
||||
let mut app = App::new();
|
||||
app.add_modules(DefaultModules);
|
||||
app.add_module(PhysicsModule);
|
||||
|
||||
let ball = app.scene.spawn("ball", Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)));
|
||||
app.scene.world_mut().insert_one(ball, RigidBody::default()).unwrap();
|
||||
app.scene.world_mut().insert_one(ball, Collider::ball(0.5)).unwrap();
|
||||
|
||||
for _ in 0..120 { app.step(); } // one fixed tick each
|
||||
// the ball has fallen; its Transform.translation.y is now lower
|
||||
```
|
||||
|
||||
### Forces & control
|
||||
|
||||
[`PhysicsWorld`] exposes by-entity control so game code never touches rapier
|
||||
handles: `set_linear_velocity` / `linear_velocity`, `apply_impulse`,
|
||||
`apply_force`, `apply_torque_impulse`, and `wake`. Reach the resource with
|
||||
`app.get_resource_mut::<PhysicsWorld>()`.
|
||||
|
||||
### Collision & trigger events
|
||||
|
||||
When two colliders start or stop touching, the step records a `CollisionEvent`
|
||||
`{ a, b, started, sensor }`. `started` distinguishes **enter** (`true`) from
|
||||
**exit** (`false`); `sensor` distinguishes a **trigger** overlap (one collider is
|
||||
a sensor — nothing was resolved, things pass through) from a **solid contact**.
|
||||
Events accumulate across every fixed sub-step and are cleared at the first step
|
||||
of each frame, so a system reading after `Update` sees them all:
|
||||
|
||||
```rust
|
||||
# use oxide_physics::PhysicsWorld;
|
||||
# fn read(app: &oxide_engine::app::App, player: oxide_engine::scene::Entity) {
|
||||
let physics = app.get_resource::<PhysicsWorld>().unwrap();
|
||||
for ev in physics.trigger_events() {
|
||||
if let Some(other) = ev.other(player) {
|
||||
if ev.started { /* player entered a trigger zone */ }
|
||||
}
|
||||
}
|
||||
# }
|
||||
```
|
||||
|
||||
"Stay" (ongoing overlap) is not an event — query the current state with
|
||||
`is_intersecting(a, b)` or `intersecting_pairs()`. Collision filtering uses the
|
||||
collider `membership`/`filter` `LayerMask`s: two colliders interact only when
|
||||
each one's membership intersects the other's filter, so a trigger can be made to
|
||||
fire only for, say, the Player layer.
|
||||
|
||||
### Scene queries
|
||||
|
||||
[`PhysicsWorld`] answers spatial questions against the simulated colliders, all
|
||||
filtered by a `LayerMask` (pass `LayerMask::ALL` to hit anything):
|
||||
|
||||
| Query | Returns |
|
||||
|-------|---------|
|
||||
| `raycast(origin, dir, max_distance, mask)` | first `RayHit { entity, toi, point, normal }` |
|
||||
| `sphere_cast(origin, radius, dir, max_distance, mask)` | first hit of a swept sphere (a "thick raycast") |
|
||||
| `overlap_sphere(center, radius, mask)` | every entity whose collider overlaps the sphere |
|
||||
| `point_overlap(point, mask)` | every entity whose collider contains the point |
|
||||
|
||||
```rust
|
||||
# use oxide_engine::math::Vec3;
|
||||
# use oxide_engine::layer::LayerMask;
|
||||
# use oxide_physics::PhysicsWorld;
|
||||
# fn pick(physics: &PhysicsWorld) {
|
||||
if let Some(hit) = physics.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL) {
|
||||
// hit.entity / hit.point / hit.normal
|
||||
}
|
||||
# }
|
||||
```
|
||||
|
||||
During simulation the step keeps the query world in sync. To query the **edited**
|
||||
scene outside Play (e.g. the editor's raycast probe), build a transient world and
|
||||
sync it once: `sync_to_scene(scene)` rebuilds the rapier world from the scene's
|
||||
`Collider`/`RigidBody` components and refreshes the query pipeline **without
|
||||
stepping**, so a following `raycast`/`overlap_*` reflects the current colliders.
|
||||
|
||||
```rust
|
||||
# use oxide_engine::math::Vec3;
|
||||
# use oxide_engine::layer::LayerMask;
|
||||
# use oxide_engine::scene::Scene;
|
||||
# use oxide_physics::PhysicsWorld;
|
||||
# fn probe(scene: &Scene) {
|
||||
let mut world = PhysicsWorld::new();
|
||||
world.sync_to_scene(scene); // query-able, no Play / no step
|
||||
let _ = world.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL);
|
||||
# }
|
||||
```
|
||||
|
||||
### Joints / constraints
|
||||
|
||||
`add_joint(a, b, kind, anchor_a, anchor_b)` connects two entities' bodies and
|
||||
returns a `JointId` (`remove_joint` undoes it). Both bodies must already be in
|
||||
the simulation (true after the first step in which their components exist). The
|
||||
`JointKind`s and the DOF they leave:
|
||||
|
||||
| Kind | Constraint |
|
||||
|------|-----------|
|
||||
| `Fixed` | a rigid weld — zero relative DOF |
|
||||
| `Spherical` | ball-and-socket — anchors stay coincident, free rotation (3 rot. DOF) |
|
||||
| `Revolute { axis }` | hinge — 1 rotational DOF about `axis` |
|
||||
| `Prismatic { axis }` | slider — 1 translational DOF along `axis` |
|
||||
|
||||
Joints live in the `PhysicsWorld` (not the ECS), so — unlike components — they
|
||||
are not captured by the play snapshot; game/setup code recreates them on each
|
||||
Play. Editor-authored joint *components* await a serializable entity-reference
|
||||
type (backlog).
|
||||
|
||||
### Character controller
|
||||
|
||||
A `CharacterController` component is a **kinematic capsule** — it never reacts to
|
||||
forces. The game asks it to move and the controller resolves that against the
|
||||
world (move-and-slide along walls, auto-step small ledges, snap to ground on
|
||||
slopes, refuse slopes steeper than `max_slope_degrees`), reporting whether the
|
||||
character ended up grounded. The entity carries only a `CharacterController` (no
|
||||
`RigidBody`/`Collider`), so it is never simulated and never self-collides.
|
||||
|
||||
```rust
|
||||
# use oxide_engine::math::Vec3;
|
||||
# use oxide_physics::PhysicsWorld;
|
||||
# fn tick(app: &mut oxide_engine::app::App, player: oxide_engine::scene::Entity, dt: f32) {
|
||||
let desired = Vec3::new(input_x, -9.81 * dt, input_z); // move + gravity
|
||||
let movement = {
|
||||
let physics = app.get_resource::<PhysicsWorld>().unwrap();
|
||||
physics.move_character(&app.scene, player, desired, dt).unwrap()
|
||||
};
|
||||
let mut t = app.scene.local_transform(player).unwrap();
|
||||
t.translation += movement.translation; // apply the resolved move
|
||||
app.scene.set_local_transform(player, t);
|
||||
// movement.grounded → e.g. allow jump
|
||||
# let (input_x, input_z) = (0.0, 0.0);
|
||||
# }
|
||||
```
|
||||
|
||||
Tune the capsule (`radius`, `half_height`) and the rules (`max_slope_degrees`,
|
||||
`step_offset`, `snap_to_ground`, `skin_width`) on the component. This is the
|
||||
basis reused by the Stage-15 prototyping kit's character.
|
||||
|
||||
### Limitations (current)
|
||||
|
||||
`Transform::scale` is not yet applied to collider dimensions (author the shape at
|
||||
its true size), and bodies are simulated in world space — keep physics bodies at
|
||||
the scene root (or under unscaled parents) for now. Both are lifted in later
|
||||
pieces.
|
||||
|
||||
## Roadmap (Stage 9 pieces)
|
||||
|
||||
1. **Component data model + module wiring** — `RigidBody`, `Collider`,
|
||||
`PhysicsModule`, `PhysicsSettings`. ✅
|
||||
2. **Rapier-backed simulation** — build the world from components, step on
|
||||
`FixedUpdate`, write transforms back, by-entity forces/velocities. ✅
|
||||
3. **Collision groups/masks via `LayerMask`, sensors, collision/trigger
|
||||
events** (enter/exit + stay queries). ✅
|
||||
4. **Scene queries** — raycast, sphere-cast, sphere/point overlap, all
|
||||
`LayerMask`-filtered. ✅
|
||||
5. **Joints/constraints** — fixed, spherical, revolute, prismatic (programmatic
|
||||
API). ✅
|
||||
6. **Kinematic character controller** — capsule move-and-slide, step offset,
|
||||
slope limit, grounded. ✅
|
||||
7. **Examples** — `physics_stack` (boxes settle + ball lands) and
|
||||
`character_capsule` (walk/climb/jump/wall), headless console demos. ✅ (this
|
||||
piece)
|
||||
8. Editor: **(8a)** RigidBody/Collider/CharacterController are addable and
|
||||
editable in the inspector (no per-type code), and `PhysicsModule` runs in
|
||||
**Play** — drop a body, watch it fall, Stop reverts it (on `dev`, awaiting
|
||||
eye-check). **(8b)** collider shape wireframe gizmos in the viewport — box,
|
||||
sphere, capsule, and cylinder outlines, **green** for solid colliders and
|
||||
**amber** for sensors (triggers), with the selected entity drawn thicker.
|
||||
The outline mirrors the simulation, which ignores `Transform::scale`, so it
|
||||
shows the exact shape rapier builds (translation + rotation only). Toggle it
|
||||
from **View ▸ Show Colliders** (on by default). **(8c)** raycast debug viz —
|
||||
a **View ▸ Raycast Probe** toggle (off by default, a debug aid to verify the
|
||||
raycast API and inspect colliders): with it on, a viewport **click** casts the
|
||||
editor camera→cursor ray against the edited scene's colliders (via
|
||||
[`PhysicsWorld::sync_to_scene`](#scene-queries), so it needs no Play) and
|
||||
**freezes** the ray into the world. The viewport then redraws it every frame —
|
||||
the ray in **cyan**, plus on a hit a **magenta** dot at the surface point and
|
||||
a short whisker along the surface normal — so **orbiting the camera reveals it
|
||||
as a real 3D line** (a ray cast from the live camera is otherwise just a point
|
||||
in that same camera's view). ✅
|
||||
|
||||
**Stage 9 is complete** — all pieces are on `main`.
|
||||
|
||||
[`PhysicsSettings`]: #adding-physics-to-an-app
|
||||
[`PhysicsWorld`]: #simulation
|
||||
@@ -0,0 +1,120 @@
|
||||
# Editor Play Mode (Stage 8.7)
|
||||
|
||||
Play mode lets the editor **run the open scene in place** — play, pause, single-
|
||||
step, and stop — driving the same fixed-timestep
|
||||
[`Schedule`](modules.md) a shipped game uses, so what you see while playing
|
||||
behaves like the real runtime. Mutations made while playing (physics moving
|
||||
bodies, scripts spawning entities) are reverted on **Stop**, so the authored
|
||||
scene is never corrupted.
|
||||
|
||||
This page covers the play-state model, the snapshot/restore that makes Stop
|
||||
safe, and how the host runner drives the engine. The standalone **"Launch"**
|
||||
button (running the *real* exported runtime in a separate process) is a Stage 16
|
||||
follow-up and is **not** part of play mode.
|
||||
|
||||
## The play states
|
||||
|
||||
`oxide_editor::state::PlayState` is a three-state machine held on `EditorState`:
|
||||
|
||||
| State | Meaning | Schedule ticked? |
|
||||
|-------|---------|------------------|
|
||||
| `Editing` | Normal authoring | no |
|
||||
| `Playing` | Running | every frame (`App::update`) |
|
||||
| `Paused` | Frozen, still live | only on **Step** (one fixed tick) |
|
||||
|
||||
Transitions are methods on `EditorState`:
|
||||
|
||||
- `enter_play()` — snapshots the scene and switches to `Playing`. No-op if
|
||||
already running (re-entering must not clobber the original snapshot).
|
||||
- `toggle_pause()` — `Playing` ⇄ `Paused`; no-op while `Editing`.
|
||||
- `stop()` — restores the snapshot, clears the selection and any in-flight gizmo
|
||||
drag (entity handles change on restore), and returns to `Editing`.
|
||||
|
||||
The shell wraps these with status messages and undo-history clearing (see
|
||||
[Controls](#controls)); `is_in_play()` is the "running or paused" predicate the
|
||||
host uses to gate the runtime.
|
||||
|
||||
## Snapshot / restore
|
||||
|
||||
Pressing Play captures a [`SceneSnapshot`](scene.md) of the current scene;
|
||||
pressing Stop restores it. Unlike `Scene::to_ron` (which records only the node-
|
||||
baked `Node`/`Transform`/hierarchy), a snapshot is **registry-aware**: it also
|
||||
serializes every reflected component on each entity, plus the engine-intrinsic
|
||||
non-reflected components (`Tags` and `DisabledComponents`). That makes Stop a
|
||||
**bit-for-bit** revert across the full component set, not just transforms.
|
||||
|
||||
```rust
|
||||
use oxide_engine::scene::SceneSnapshot;
|
||||
|
||||
let snap = scene.snapshot(®istry); // capture
|
||||
// … play-mode mutations …
|
||||
let scene = snap.restore(®istry)?; // revert (fresh entity handles)
|
||||
```
|
||||
|
||||
Fidelity is bounded by what the [`TypeRegistry`](reflection.md) knows: a
|
||||
component that is neither registered nor one of the two intrinsics is invisible
|
||||
to capture. Modules register their components anyway (that is what makes them
|
||||
editable), so they survive play mode automatically.
|
||||
|
||||
`SceneSnapshot` also exposes `to_ron`/`from_ron`, so the same capture format
|
||||
seeds full scene files in a later stage.
|
||||
|
||||
## Driving the schedule
|
||||
|
||||
The editor holds the live scene in `EditorState.scene`, not in an
|
||||
[`App`](modules.md). On Play the host runner (`oxide_editor::main`) builds a play
|
||||
`App` (currently just `DefaultModules`; Stage 9's physics module and the
|
||||
project's modules will register here too) and, each frame:
|
||||
|
||||
1. **swaps** `state.scene` into `app.scene` (an O(1) move),
|
||||
2. advances the engine, then
|
||||
3. **swaps** the scene back out.
|
||||
|
||||
So the `App` only "holds" the editor scene for the duration of a tick, and
|
||||
`state.scene` stays the single source of truth the inspector, hierarchy, and
|
||||
viewport read between frames. The `App` persists its `Time` and resources across
|
||||
frames (so a physics world accumulates correctly) and is dropped on Stop.
|
||||
|
||||
How far to advance is decided by a small pure function,
|
||||
`oxide_editor::play::tick_for`, kept separate from the (un-testable) GUI runner
|
||||
so the contract is pinned in a unit test:
|
||||
|
||||
```rust
|
||||
use oxide_editor::play::{tick_for, Tick};
|
||||
|
||||
match tick_for(state.play, step_requested) {
|
||||
Tick::Frame => app.update(dt), // Playing
|
||||
Tick::FixedStep => app.step(), // Paused + Step
|
||||
Tick::Idle => {} // Editing, or Paused with no Step
|
||||
}
|
||||
```
|
||||
|
||||
`App::step()` (engine) advances **exactly one fixed timestep** — one
|
||||
`FixedUpdate` plus the per-frame phases once, bypassing the accumulator — which
|
||||
is the Step primitive. `App::update(dt)` runs a normal frame, fixed steps driven
|
||||
by the accumulator as usual (see [modules.md](modules.md)).
|
||||
|
||||
## Controls
|
||||
|
||||
A toolbar below the menu bar exposes **Play / Pause / Step / Stop**, gated by the
|
||||
play state (Pause/Step/Stop enable only while running) with an
|
||||
`Editing`/`PLAYING`/`PAUSED` badge. The viewport additionally draws a coloured
|
||||
border + corner label while running (green = playing, amber = paused) so
|
||||
edit-vs-play is unmistakable.
|
||||
|
||||
Shortcuts:
|
||||
|
||||
- **Ctrl+P** — Play when editing; Pause ⇄ Resume while running.
|
||||
- **Ctrl+.** — Step one fixed tick (while paused).
|
||||
|
||||
Entering Play and pressing Stop both **clear the undo history**: the scene is
|
||||
restored wholesale on Stop, so play-mode edits are deliberately not part of
|
||||
edit-mode undo. The reflection inspector and gizmos stay live while paused (and
|
||||
playing), so a field can be tweaked and the result observed immediately — the
|
||||
payoff of the Stage-8.5 reflection work.
|
||||
|
||||
## Not in play mode (Stage 16)
|
||||
|
||||
A **"Launch standalone"** button that runs the real exported runtime in a
|
||||
separate window/process — the truest-to-ship check — is deferred to Stage 16,
|
||||
where it reuses the export builder rather than the in-editor loop.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Prefabs
|
||||
|
||||
`oxide_engine::prefab` provides **named spawn templates**. A prefab is a thing
|
||||
you can "drop into the scene" that already carries a set of components — a
|
||||
`Cube`, a `Camera`, a `Directional Light` — without the editor or game code
|
||||
hard-coding one spawn path per kind of object.
|
||||
|
||||
## An entity is its components — a prefab is just data
|
||||
|
||||
Oxide has **no parallel "object type" system**. An entity *is* its set of
|
||||
components (see [scene.md](scene.md) and [reflection.md](reflection.md)). A
|
||||
[`Prefab`] is therefore nothing more than a **name** plus a list of
|
||||
**(component name, RON value)** specs, applied on spawn through the
|
||||
[`TypeRegistry`](reflection.md):
|
||||
|
||||
> "Spawn a Cube" = spawn an entity, then set its `MeshRenderer` to a cube.
|
||||
|
||||
Because a spec is the same name-keyed RON the editor and scripts already use,
|
||||
prefabs are **pure data**: serializable, dual-editable, and free of bespoke
|
||||
code. This is what lets the editor's add-menu be **data-driven** — it lists the
|
||||
prefabs in a [`PrefabRegistry`] instead of one hard-coded button per type.
|
||||
|
||||
## The types
|
||||
|
||||
- [`ComponentSpec`] — `{ type_name, ron }`: one component to attach. Build it
|
||||
from a value with [`ComponentSpec::of`] (serializes to RON) or from a raw
|
||||
string with [`ComponentSpec::new`].
|
||||
- [`Prefab`] — `{ name, components }`: the node name plus the specs to apply.
|
||||
Built fluently with [`Prefab::new`] + [`Prefab::with`].
|
||||
- [`PrefabRegistry`] — prefabs keyed by name; the source for an add-menu.
|
||||
|
||||
Every spawned entity already carries the node-baked `Node`, `Transform`, and
|
||||
`Layer` (auto-attached by [`Scene::spawn`](scene.md)); a prefab's specs are
|
||||
layered on top. A spec named `"Transform"` overrides the identity transform
|
||||
`spawn` starts with, so a prefab can place itself.
|
||||
|
||||
## Spawning
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::prefab::{ComponentSpec, Prefab, PrefabRegistry};
|
||||
use oxide_engine::reflect::TypeRegistry;
|
||||
|
||||
// A registry that knows how to round-trip MeshRenderer by name.
|
||||
let mut types = TypeRegistry::new();
|
||||
types.register_reflected::<MeshRenderer>("MeshRenderer");
|
||||
|
||||
// A "Cube" prefab: a default MeshRenderer (shape = Cube).
|
||||
let mut prefabs = PrefabRegistry::new();
|
||||
prefabs.register(
|
||||
Prefab::new("Cube")
|
||||
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
|
||||
);
|
||||
|
||||
let mut scene = Scene::new();
|
||||
let cube = prefabs.spawn("Cube", &mut scene, &types).unwrap(); // root entity
|
||||
let child = prefabs.spawn_child("Cube", cube, &mut scene, &types); // parented
|
||||
```
|
||||
|
||||
[`PrefabRegistry::spawn`] returns the new entity, or `None` if the name isn't
|
||||
registered. Application is **best-effort**: a spec whose type isn't registered
|
||||
or whose RON doesn't parse is skipped (the entity is still created with whatever
|
||||
applied). Validate a prefab against a registry up front with
|
||||
[`PrefabRegistry::unknown_specs`], which lists the type names the registry
|
||||
doesn't know — handy for catching authoring typos.
|
||||
|
||||
## Relationship to "multiple of the same component"
|
||||
|
||||
Prefabs spawn **one entity**. Because an archetypal ECS allows only one
|
||||
component of a given type per entity, a prefab that needs several of a thing
|
||||
(e.g. multiple meshes) composes them as **child entities** — spawn the prefab,
|
||||
then `spawn_child` the extras (each a real, gizmo-movable node). See the
|
||||
component-multiplicity notes in `PLAN.md`.
|
||||
|
||||
[`ComponentSpec`]: ../engine/src/prefab.rs
|
||||
[`ComponentSpec::of`]: ../engine/src/prefab.rs
|
||||
[`ComponentSpec::new`]: ../engine/src/prefab.rs
|
||||
[`Prefab`]: ../engine/src/prefab.rs
|
||||
[`Prefab::new`]: ../engine/src/prefab.rs
|
||||
[`Prefab::with`]: ../engine/src/prefab.rs
|
||||
[`PrefabRegistry`]: ../engine/src/prefab.rs
|
||||
[`PrefabRegistry::spawn`]: ../engine/src/prefab.rs
|
||||
[`PrefabRegistry::unknown_specs`]: ../engine/src/prefab.rs
|
||||
@@ -0,0 +1,85 @@
|
||||
# Project System
|
||||
|
||||
`oxide_engine::project` defines what a game is, on disk: a **project**. A project
|
||||
is a root directory containing a project file plus a defined folder layout. The
|
||||
format lives in the engine (not the editor) because the exported runtime and the
|
||||
Stage-16 packer read it too — the editor just adds the create/open/save UI.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
my-game/
|
||||
├── project.oxide # the project file (RON)
|
||||
├── scenes/ # scene files
|
||||
├── assets/ # meshes, textures, audio, …
|
||||
└── scripts/ # game scripts
|
||||
```
|
||||
|
||||
The project file records the project name, the engine version it was saved with,
|
||||
the enabled [modules](modules.md), and per-project settings.
|
||||
|
||||
## Create, open, save
|
||||
|
||||
```rust
|
||||
use oxide_engine::project::Project;
|
||||
|
||||
// Scaffold a new project (creates the folders + project file).
|
||||
let mut project = Project::create("/path/to/my-game", "My Game")?;
|
||||
|
||||
project.enable_module("render");
|
||||
project.save()?;
|
||||
|
||||
// Reopen later — by directory or by the project file path.
|
||||
let project = Project::open("/path/to/my-game")?;
|
||||
assert_eq!(project.name(), "My Game");
|
||||
assert!(project.is_module_enabled("render"));
|
||||
# Ok::<(), oxide_engine::project::ProjectError>(())
|
||||
```
|
||||
|
||||
Path helpers (`scenes_dir()`, `assets_dir()`, `scripts_dir()`,
|
||||
`project_file_path()`) resolve locations against the root. `create` refuses to
|
||||
overwrite an existing project; `open` reports `NotFound` when there is no project
|
||||
file.
|
||||
|
||||
## Settings storage
|
||||
|
||||
Per-project settings are stored as **opaque per-section RON blobs** keyed by
|
||||
section name, which keeps the project format independent of any particular
|
||||
settings schema:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::project::Project;
|
||||
# let mut project = Project::create(std::env::temp_dir().join("oxide_doc_proj"), "x").unwrap();
|
||||
project.set_settings_section("editor", "(theme:\"dark\")");
|
||||
assert_eq!(project.settings_section("editor"), Some("(theme:\"dark\")"));
|
||||
```
|
||||
|
||||
The typed settings framework serializes its sections to and from these strings,
|
||||
so a section round-trips through the project file without this module knowing the
|
||||
section's shape.
|
||||
|
||||
## Recent projects
|
||||
|
||||
`RecentProjects` is a small most-recently-used list, persisted globally as an
|
||||
editor preference (not inside any project). It de-duplicates and caps:
|
||||
|
||||
```rust
|
||||
use oxide_engine::project::RecentProjects;
|
||||
|
||||
let mut recent = RecentProjects::new(10);
|
||||
recent.record("/path/to/my-game");
|
||||
// recent.save("~/.config/oxide/recent.ron")?; / RecentProjects::load(...)
|
||||
assert_eq!(recent.entries().len(), 1);
|
||||
```
|
||||
|
||||
## Who consumes this
|
||||
|
||||
| Consumer | Stage | Use |
|
||||
|----------|-------|-----|
|
||||
| Editor | 6 | New/Open/Save Project UI, recent list, Project panel/asset browser |
|
||||
| File watcher | 6 | watches `scenes/`, `assets/`, `scripts/` for live reload |
|
||||
| Settings framework | 6 | persists per-project sections into the project file |
|
||||
| Exported runtime / packer | 16 | reads the layout + enabled modules to bundle the game |
|
||||
|
||||
[`Project`]: ../engine/src/project.rs
|
||||
[`RecentProjects`]: ../engine/src/project.rs
|
||||
@@ -0,0 +1,191 @@
|
||||
# Reflection / Type Registry
|
||||
|
||||
`oxide_engine::reflect` is the backbone of the engine's **dual-editable types**
|
||||
principle: every component should be readable and writable from the editor, from
|
||||
scripts, and from external tools through *one* representation — without each of
|
||||
those callers knowing the concrete Rust type.
|
||||
|
||||
The [`TypeRegistry`] is that bridge. It lands in Stage 5; the editor inspector
|
||||
(Stage 6) and the scripting layer (Stage 10) are its first real consumers.
|
||||
|
||||
## The idea
|
||||
|
||||
ECS components are concrete Rust types. An inspector panel or a script engine,
|
||||
however, only has a *name* (`"Transform"`) and some text — they cannot name the
|
||||
type at the call site. The registry closes that gap: register a type once, and
|
||||
afterwards address it generically by name.
|
||||
|
||||
```rust
|
||||
use oxide_engine::reflect::TypeRegistry;
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let mut registry = TypeRegistry::new();
|
||||
registry.register::<Transform>("Transform");
|
||||
registry.register::<Node>("Node");
|
||||
```
|
||||
|
||||
`register::<T>(name)` requires `T: Component + Serialize + DeserializeOwned`.
|
||||
Internally it stores a small set of monomorphized function pointers, so there is
|
||||
no per-call generic dispatch and no `dyn Any` downcasting at the boundary.
|
||||
|
||||
## Generic read / write
|
||||
|
||||
Once registered, any holder of the name can round-trip a component on an entity
|
||||
as RON text — exactly what a generic inspector or a script needs:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::reflect::TypeRegistry;
|
||||
# use oxide_engine::prelude::*;
|
||||
# let mut registry = TypeRegistry::new();
|
||||
# registry.register::<Transform>("Transform");
|
||||
let mut scene = Scene::new();
|
||||
let e = scene.spawn("thing", Transform::IDENTITY);
|
||||
|
||||
// Read generically...
|
||||
let text = registry.get_ron(scene.world(), e, "Transform").unwrap();
|
||||
// ...edit the text (a script or the inspector would)...
|
||||
// ...and write it back — no concrete type at the call site.
|
||||
registry.set_ron(scene.world_mut(), e, "Transform", &text).unwrap();
|
||||
```
|
||||
|
||||
`set_ron` inserts the component if absent or replaces it if present, so the same
|
||||
call covers "add component" and "edit component".
|
||||
|
||||
## Enumerating an entity's components
|
||||
|
||||
A generic inspector renders an entity by asking the registry which *registered*
|
||||
component types it currently carries — sorted, and again with no concrete types
|
||||
in hand:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::reflect::TypeRegistry;
|
||||
# use oxide_engine::prelude::*;
|
||||
# let mut registry = TypeRegistry::new();
|
||||
# registry.register::<Transform>("Transform");
|
||||
# registry.register::<Node>("Node");
|
||||
# let mut scene = Scene::new();
|
||||
# let e = scene.spawn("thing", Transform::IDENTITY);
|
||||
for name in registry.components_on(scene.world(), e) {
|
||||
let _ron = registry.get_ron(scene.world(), e, name).unwrap();
|
||||
// ... render an editor for `name` from its RON text
|
||||
}
|
||||
```
|
||||
|
||||
`has` and `remove` round out the surface (check for / detach a component by
|
||||
name). Errors are specific — [`UnknownType`], [`NoSuchEntity`], [`Missing`], and
|
||||
[`Parse`] — so callers can tell "no such type" from "bad text".
|
||||
|
||||
## Per-field reflection — `#[derive(Reflect)]` (Stage 8.5)
|
||||
|
||||
Whole-value reflection is enough for serialization and scripts, but a
|
||||
Unity/Godot-style inspector needs to see a component's **named fields** so it
|
||||
can render one widget per field. The [`Reflect`] trait provides that, and
|
||||
`#[derive(Reflect)]` generates it:
|
||||
|
||||
```rust
|
||||
use oxide_engine::reflect::Reflect;
|
||||
|
||||
#[derive(Reflect, serde::Serialize, serde::Deserialize)]
|
||||
struct Timer {
|
||||
pub repeating: bool,
|
||||
pub duration: f32,
|
||||
#[reflect(skip)]
|
||||
pub elapsed: f32, // runtime state — not an authored field
|
||||
}
|
||||
|
||||
let mut t = Timer { repeating: true, duration: 2.5, elapsed: 0.0 };
|
||||
|
||||
// Enumerate fields (name + syntactic type) — what an inspector iterates.
|
||||
for field in t.fields() {
|
||||
let _value_ron = t.get_field(field.name); // Some("true"), Some("2.5"), …
|
||||
// pick a widget from `field.type_name`: "bool" → checkbox, "f32" → drag, …
|
||||
}
|
||||
|
||||
// Edit one field without touching the rest; round-trips as RON.
|
||||
t.set_field("duration", "9.0").unwrap();
|
||||
```
|
||||
|
||||
Selection rules (deliberate, matching the engine's *public-fields-are-the-
|
||||
editable-surface* convention):
|
||||
|
||||
- **Only `pub` fields** are reflected. Private fields are implementation detail.
|
||||
- `#[reflect(skip)]` excludes a public field (e.g. runtime-only state).
|
||||
- Each reflected field must be `serde`-serializable — get/set round-trip through
|
||||
RON, the same representation the whole-value path uses.
|
||||
|
||||
[`FieldInfo::type_name`] is the field type's *syntactic* spelling (`"f32"`,
|
||||
`"bool"`, `"Vec3"`, `"Handle < Font >"`). The generic inspector dispatches a
|
||||
widget on it and falls back to a raw RON editor for types it does not recognize.
|
||||
Per-field errors are [`UnknownField`] and [`FieldParse`].
|
||||
|
||||
The derive lives in the small `oxide-engine-derive` proc-macro crate and is
|
||||
re-exported as `oxide_engine::reflect::Reflect` (the macro shares its name with
|
||||
the trait, the same way `serde`'s `Serialize` does). This is the spine of the
|
||||
reflection-driven editor inspector and the dual-editable-types principle — a new
|
||||
component becomes editor- and script-editable with `#[derive(Reflect)]` plus one
|
||||
registration line, no per-type editor code.
|
||||
|
||||
### Through the registry: fields by type name + entity
|
||||
|
||||
A type registered with `register_reflected::<T>("Name")` (instead of plain
|
||||
`register`) exposes its fields through the [`TypeRegistry`] too, so the editor
|
||||
can reach a field given only a **type name + entity** — no concrete type at the
|
||||
call site:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::reflect::TypeRegistry;
|
||||
# use oxide_engine::prelude::*;
|
||||
# let mut registry = TypeRegistry::new();
|
||||
registry.register_reflected::<Transform>("Transform");
|
||||
# let mut scene = Scene::new();
|
||||
# let e = scene.spawn("thing", Transform::IDENTITY);
|
||||
for field in registry.field_infos(scene.world(), e, "Transform").unwrap() {
|
||||
let _ron = registry.get_field(scene.world(), e, "Transform", field.name).unwrap();
|
||||
// render a widget from field.type_name, write edits back with set_field(...)
|
||||
}
|
||||
```
|
||||
|
||||
`Transform` and `Node` are registered reflected by default. Field access on a
|
||||
whole-value-only type returns [`NotReflected`]. Whole-value (`get_ron`/`set_ron`)
|
||||
and per-field (`field_infos`/`get_field`/`set_field`) coexist: the registry
|
||||
addresses *types* by name; per-field reaches *fields* within a value.
|
||||
|
||||
### Enum fields — `#[derive(ReflectEnum)]`
|
||||
|
||||
Per-field reflection tells the inspector a field's *type name* but not, for an
|
||||
enum-typed field, which values it may take. `#[derive(ReflectEnum)]` (unit
|
||||
variants only) exposes the variant list so the inspector renders a dropdown
|
||||
instead of a free-text RON box:
|
||||
|
||||
```rust
|
||||
use oxide_engine::reflect::{ReflectEnum, TypeRegistry};
|
||||
|
||||
#[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
|
||||
enum Facing { North, East, South, West }
|
||||
|
||||
let mut registry = TypeRegistry::new();
|
||||
registry.register_enum::<Facing>("Facing");
|
||||
assert_eq!(registry.enum_variants("Facing"), Some(["North","East","South","West"].as_slice()));
|
||||
```
|
||||
|
||||
Each variant name is valid RON for that unit variant, so a chosen name writes
|
||||
straight back through `set_field`. An enum is a field *type*, not a component, so
|
||||
`register_enum` is independent of component registration.
|
||||
|
||||
## Who owns the registry
|
||||
|
||||
The registry is owned by the app / module system (Stage 5): each module
|
||||
registers the component types it introduces, so the editor and scripts can reach
|
||||
every type any module added. Because names are the identity used in serialized
|
||||
data and UI, keep them stable across versions.
|
||||
|
||||
[`TypeRegistry`]: ../engine/src/reflect.rs
|
||||
[`UnknownType`]: ../engine/src/reflect.rs
|
||||
[`NoSuchEntity`]: ../engine/src/reflect.rs
|
||||
[`Missing`]: ../engine/src/reflect.rs
|
||||
[`Parse`]: ../engine/src/reflect.rs
|
||||
[`Reflect`]: ../engine/src/reflect.rs
|
||||
[`FieldInfo::type_name`]: ../engine/src/reflect.rs
|
||||
[`UnknownField`]: ../engine/src/reflect.rs
|
||||
[`FieldParse`]: ../engine/src/reflect.rs
|
||||
[`NotReflected`]: ../engine/src/reflect.rs
|
||||
@@ -0,0 +1,105 @@
|
||||
# Render Context & GPU Setup
|
||||
|
||||
Stage 2 reference for `oxide_engine::render` — how the engine acquires the
|
||||
GPU and drives a window surface. For the event loop that calls into this each
|
||||
frame, see [windowing.md](windowing.md).
|
||||
|
||||
## Overview
|
||||
|
||||
Stage 2 rendering is deliberately minimal: acquire the GPU, configure the
|
||||
window surface, and clear it to a configurable color every frame. Meshes,
|
||||
materials, and passes arrive in Stage 4+. The module still establishes the
|
||||
two long-lived types every later stage builds on:
|
||||
|
||||
- **`Gpu`** — instance, adapter, and the device/queue pair. Everything that
|
||||
touches the GPU goes through these four objects.
|
||||
- **`RenderContext`** — a `Gpu` plus a window's surface and its
|
||||
configuration; owns the per-frame acquire → clear → present cycle.
|
||||
|
||||
Both are created for you by [`run()`](windowing.md); applications normally
|
||||
reach them through `AppCtx::render()`.
|
||||
|
||||
## `Gpu`
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let gpu = Gpu::headless()?; // offscreen / tests
|
||||
let device: &wgpu::Device = gpu.device();
|
||||
let queue: &wgpu::Queue = gpu.queue();
|
||||
# Ok::<(), oxide_engine::render::RenderError>(())
|
||||
```
|
||||
|
||||
Acquisition asks for a high-performance adapter (compatible with the window
|
||||
surface in the windowed path) and a default-limits device. The chosen adapter
|
||||
and backend are logged at `info` level on startup.
|
||||
|
||||
`Gpu::headless()` skips the surface entirely — used by offscreen rendering
|
||||
and the automated Stage 2 integration test. Backend selection and debug flags
|
||||
remain overridable through wgpu's standard `WGPU_*` environment variables
|
||||
(e.g. `WGPU_BACKEND=vulkan`).
|
||||
|
||||
## `RenderContext`
|
||||
|
||||
Owns the surface lifecycle:
|
||||
|
||||
- **Creation** — builds the wgpu instance (the window doubles as the display
|
||||
handle), creates the surface, acquires the `Gpu`, and configures the
|
||||
surface with `get_default_config` (the platform's preferred format and
|
||||
present mode).
|
||||
- **`resize(width, height)`** — reconfigures the surface. Zero dimensions
|
||||
(minimized windows) are clamped to 1 so the surface stays valid. Called
|
||||
automatically by the event loop on `Resized`.
|
||||
- **`set_clear_color(color)` / `clear_color()`** — the color the next frame
|
||||
is cleared to. The engine's `Color` is linear f32 RGBA, matching what the
|
||||
surface expects (conversion to `wgpu::Color` is `render::to_wgpu_color`).
|
||||
- **`render_frame()`** — one frame: acquire the next surface texture, record
|
||||
a clear pass, submit, present.
|
||||
- **`size()`, `gpu()`** — current surface size (physical pixels) and the
|
||||
underlying `Gpu`.
|
||||
|
||||
### Frame acquisition and transient failures
|
||||
|
||||
`get_current_texture` can fail for reasons that are *normal* during resizes
|
||||
and window-manager activity. `render_frame()` maps them as follows:
|
||||
|
||||
| Surface state | Behavior |
|
||||
|---------------|----------|
|
||||
| `Success` / `Suboptimal` | Clear and present (a suboptimal frame is still presentable; the next resize reconfigures anyway) |
|
||||
| `Lost` / `Outdated` | Reconfigure the surface, skip the frame |
|
||||
| `Timeout` / `Occluded` | Skip the frame |
|
||||
| `Validation` | Returned as `RenderError::SurfaceValidation` — a real bug, not transient |
|
||||
|
||||
Skipped frames are invisible in practice: the next `RedrawRequested` arrives
|
||||
within milliseconds.
|
||||
|
||||
## `clear_view`
|
||||
|
||||
The single render operation Stage 2 owns:
|
||||
|
||||
```rust
|
||||
oxide_engine::render::clear_view(device, queue, &texture_view, Color::RED);
|
||||
```
|
||||
|
||||
Records and submits a render pass whose only work is a load-op clear. Both
|
||||
the windowed path (`render_frame`) and offscreen targets go through it, which
|
||||
is what makes the GPU path automatically testable: the integration test
|
||||
`stage2::headless_clear_fills_texture_with_clear_color` clears an offscreen
|
||||
texture headless, reads the pixels back, and asserts the exact clear color —
|
||||
no window or human needed. (It self-skips on machines with no GPU adapter.)
|
||||
|
||||
## Errors
|
||||
|
||||
`RenderError` (a `thiserror` enum) distinguishes the failure modes callers
|
||||
might handle: `NoAdapter`, `Device`, `CreateSurface`, `UnsupportedSurface`,
|
||||
and `SurfaceValidation`. Binaries typically just propagate it via `anyhow`
|
||||
out of `run()`.
|
||||
|
||||
## Design notes
|
||||
|
||||
- The window is held as `Arc<winit::window::Window>` so the surface, which
|
||||
borrows the window, can be `'static` — winit hands windows out from inside
|
||||
its event loop, and wgpu surfaces must outlive every frame.
|
||||
- `Gpu` and `RenderContext` are separate types on purpose: later stages (and
|
||||
tests today) need the device/queue without any window, and composability is
|
||||
a core project principle.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Render Pass Pipeline
|
||||
|
||||
Stage 4 drew everything in one hardcoded pass. Stage 5 generalizes that into a
|
||||
[`RenderPipeline`]: an ordered, named list of composable [`RenderPass`]es that
|
||||
share one frame's targets. A project enables only the passes it needs — this is
|
||||
the mechanism behind **scalable fidelity**: a flat unlit/low-poly look (or a
|
||||
stylized post effect like a VCR filter) versus a full realistic stack with
|
||||
shadows and post-processing, paying only for the passes turned on.
|
||||
|
||||
The Stage-4 forward renderer is retrofitted onto this as [`ForwardPass`], so the
|
||||
default pipeline is just `[Clear, Forward]` and produces pixel-identical output.
|
||||
Later stages (shadows, post-process, overlay UI) add passes **without touching
|
||||
the renderer core** — they register a pass.
|
||||
|
||||
## The pieces
|
||||
|
||||
- [`RenderPass`] — a trait with one method, `run(&mut self, frame)`. Implement it
|
||||
to add a stage of the frame.
|
||||
- [`FrameContext`] — everything a pass operates on for one frame: the shared
|
||||
`color` target, size, clear color, camera + its world transform, lighting, and
|
||||
the (already culled) drawables.
|
||||
- [`RenderPipeline`] — owns the passes and runs every *enabled* one in order.
|
||||
- Built-in passes: [`ClearPass`] (clears the color target) and [`ForwardPass`]
|
||||
(the lit forward draw).
|
||||
|
||||
## Composing a frame
|
||||
|
||||
```rust
|
||||
use oxide_engine::render::{RenderPipeline, FrameContext, ForwardPass};
|
||||
# use oxide_engine::prelude::*;
|
||||
# fn demo(device: &oxide_engine::wgpu::Device, queue: &oxide_engine::wgpu::Queue,
|
||||
# target: &oxide_engine::wgpu::TextureView, cube: &GpuMesh) {
|
||||
// The default pipeline: Clear then Forward (pixel-identical to Stage 4).
|
||||
let mut pipeline = RenderPipeline::forward(device, oxide_engine::wgpu::TextureFormat::Rgba8Unorm);
|
||||
|
||||
let camera = Camera::default();
|
||||
let view = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
let lighting = Lighting::default();
|
||||
let objects = [RenderObject { mesh: cube, material: Material::diffuse(Color::RED), transform: Transform::IDENTITY }];
|
||||
|
||||
pipeline.render(&mut FrameContext {
|
||||
device, queue,
|
||||
color: target,
|
||||
size: (1280, 720),
|
||||
clear_color: Color::rgb(0.05, 0.06, 0.09),
|
||||
camera: &camera,
|
||||
view_transform: &view,
|
||||
lighting: &lighting,
|
||||
objects: &objects,
|
||||
});
|
||||
# }
|
||||
```
|
||||
|
||||
## Data-driven: add, toggle, remove
|
||||
|
||||
Passes are addressed by name and managed without touching any pass's code:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::render::RenderPipeline;
|
||||
# struct Bloom; impl oxide_engine::render::RenderPass for Bloom {
|
||||
# fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} }
|
||||
# let mut pipeline = RenderPipeline::new();
|
||||
pipeline.add_pass("forward", /* ForwardPass */
|
||||
# { struct F; impl oxide_engine::render::RenderPass for F { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } F }
|
||||
);
|
||||
pipeline.add_pass("bloom", Bloom); // a post effect (Stage 13)
|
||||
pipeline.set_enabled("bloom", false); // turn it off, keep it registered
|
||||
pipeline.insert_before("forward", "shadows",
|
||||
# { struct S; impl oxide_engine::render::RenderPass for S { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } S }
|
||||
); // slot a pass into a fixed position
|
||||
pipeline.remove("bloom"); // drop it entirely
|
||||
```
|
||||
|
||||
A stylized game ships a pipeline with no post passes (and pays nothing for them);
|
||||
a realistic game enables shadows, SSAO, bloom, tone-mapping. Same engine, same
|
||||
renderer core — different pass list.
|
||||
|
||||
## Windowed vs offscreen clearing
|
||||
|
||||
The window runner already clears the surface to the configured clear color before
|
||||
`App::render` runs, so the **editor and windowed examples use a forward-only
|
||||
pipeline** (no `ClearPass`) and let the runner clear. `RenderPipeline::forward`
|
||||
(Clear + Forward) is for offscreen/standalone rendering where nothing else
|
||||
clears the target — e.g. the headless render tests.
|
||||
|
||||
## Camera layer visibility
|
||||
|
||||
A [`Camera`](rendering.md) carries a `visibility` [`LayerMask`](layers.md): it
|
||||
renders an entity only if the entity's [`Layer`] is in that mask (default
|
||||
[`LayerMask::ALL`] — sees everything). The host applies it while gathering
|
||||
drawables:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::prelude::*;
|
||||
# use oxide_engine::layer::Layer;
|
||||
# let scene = Scene::new();
|
||||
# let camera = Camera::default();
|
||||
# let entity = scene.entities().next();
|
||||
# if let Some(entity) = entity {
|
||||
let layer = scene.get::<Layer>(entity).map(|l| *l).unwrap_or_default();
|
||||
if camera.sees(layer) {
|
||||
// include this entity in the draw list
|
||||
}
|
||||
# }
|
||||
```
|
||||
|
||||
This is how a minimap camera, a first-person view-model camera, or editor-only
|
||||
gizmo layers are kept to their own cameras.
|
||||
|
||||
[`RenderPipeline`]: ../engine/src/render/pipeline.rs
|
||||
[`RenderPass`]: ../engine/src/render/pipeline.rs
|
||||
[`FrameContext`]: ../engine/src/render/pipeline.rs
|
||||
[`ClearPass`]: ../engine/src/render/pipeline.rs
|
||||
[`ForwardPass`]: ../engine/src/render/pipeline.rs
|
||||
[`Layer`]: ../engine/src/layer/components.rs
|
||||
@@ -0,0 +1,186 @@
|
||||
# Rendering (Stage 4 — Basic 3D Rendering)
|
||||
|
||||
Stage 4 turns the clear-color surface from Stage 2 into a 3D renderer: it draws
|
||||
**meshes**, placed by **transforms**, shaded by **materials**, as seen through a
|
||||
**camera**, lit by a directional light — all through a single-pass
|
||||
**forward renderer**.
|
||||
|
||||
> Status: the rendering core (this document), the glTF importer, and the
|
||||
> editor's 3D viewport (orbit/pan/zoom + material inspector) are all implemented.
|
||||
> The engine paths are covered by headless GPU tests; the editor viewport is on
|
||||
> `dev` awaiting the maintainer's manual sign-off before Stage 4 is marked done
|
||||
> (tracked in [PLAN.md](../PLAN.md)).
|
||||
|
||||
All of these types live in `oxide_engine::render` and are re-exported from the
|
||||
[prelude](getting-started.md).
|
||||
|
||||
## The pieces
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| [`Vertex`] | One vertex: `position`, `normal`, `uv` (GPU-ready, `repr(C)`) |
|
||||
| [`Mesh`] | CPU-side indexed triangle geometry + primitive builders |
|
||||
| [`GpuMesh`] | A `Mesh` uploaded into GPU vertex/index buffers |
|
||||
| [`Material`] | PBR-lite surface: `albedo`, `metallic`, `roughness` |
|
||||
| [`Camera`] | Perspective projection; the *view* comes from a `Transform` |
|
||||
| [`DirectionalLight`] / [`Lighting`] | One sun light + an ambient term |
|
||||
| [`RenderObject`] | A drawable: `&GpuMesh` + `Material` + `Transform` |
|
||||
| [`ForwardRenderer`] | Owns the pipeline + depth buffer; draws a list of objects |
|
||||
|
||||
[`Vertex`]: ../engine/src/render/mesh.rs
|
||||
[`Mesh`]: ../engine/src/render/mesh.rs
|
||||
[`GpuMesh`]: ../engine/src/render/mesh.rs
|
||||
[`Material`]: ../engine/src/render/material.rs
|
||||
[`Camera`]: ../engine/src/render/camera.rs
|
||||
[`DirectionalLight`]: ../engine/src/render/forward.rs
|
||||
[`Lighting`]: ../engine/src/render/forward.rs
|
||||
[`RenderObject`]: ../engine/src/render/forward.rs
|
||||
[`ForwardRenderer`]: ../engine/src/render/forward.rs
|
||||
|
||||
## Building geometry
|
||||
|
||||
Meshes are built on the CPU and uploaded once. Built-in primitives cover the
|
||||
common prototyping shapes:
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let cube = Mesh::cube(); // unit cube, per-face normals
|
||||
let plane = Mesh::plane(10.0); // 10×10 ground on XZ, facing +Y
|
||||
let sphere = Mesh::uv_sphere(0.8, 32, 16); // radius, sectors, stacks
|
||||
|
||||
// Upload to the GPU (needs a `&wgpu::Device`, e.g. from `RenderCtx`/`Gpu`).
|
||||
let gpu_cube: GpuMesh = cube.upload(device, "cube");
|
||||
```
|
||||
|
||||
You can also build a mesh directly from `Vertex` + index data, and query its
|
||||
object-space bounds with `Mesh::bounds()` (used later for culling).
|
||||
|
||||
### Importing glTF
|
||||
|
||||
Static meshes load from glTF/GLB via `oxide_engine::asset`. The node hierarchy is
|
||||
flattened into world space and each primitive becomes a `GltfMesh` (geometry +
|
||||
PBR-lite material + transform); missing normals are generated, missing UVs default
|
||||
to zero. Skinning/animation are deferred to the animation stage.
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let model = load_gltf("assets/models/cube.gltf")?;
|
||||
let drawables: Vec<_> = model
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|m| (m.mesh.upload(device, "gltf"), m.material, m.transform))
|
||||
.collect();
|
||||
// Build `RenderObject`s from `drawables` and hand them to `ForwardRenderer::render`.
|
||||
```
|
||||
|
||||
`load_gltf_slice(&bytes)` is the in-memory variant (buffers must be embedded),
|
||||
used for tests and bundled assets.
|
||||
|
||||
## Camera
|
||||
|
||||
A `Camera` holds only projection parameters (`fov_y`, `z_near`, `z_far`); its
|
||||
*position and orientation* are a [`Transform`](scene.md) given at render time, so
|
||||
a camera can live in the scene as an entity. Use `Transform::looking_at` to aim
|
||||
it:
|
||||
|
||||
```rust
|
||||
let camera = Camera::default(); // 60° FOV, 0.1–1000 range
|
||||
let view = Transform::looking_at(Vec3::new(4.0, 2.5, 5.0), Vec3::ZERO, Vec3::Y);
|
||||
```
|
||||
|
||||
The projection uses a `0..1` NDC depth range (the wgpu/Vulkan/DX/Metal
|
||||
convention), matching the depth buffer the forward renderer clears to `1.0`.
|
||||
|
||||
## Drawing a frame
|
||||
|
||||
The `ForwardRenderer` is built once for a given **color target format** — the
|
||||
window surface format for on-screen rendering, or e.g. `Rgba8Unorm` offscreen.
|
||||
Then each frame you hand it a list of `RenderObject`s:
|
||||
|
||||
```rust
|
||||
// Once (e.g. lazily on the first frame, when the surface format is known):
|
||||
let mut renderer = ForwardRenderer::new(device, ctx.surface_format);
|
||||
|
||||
// Each frame, inside `App::render`:
|
||||
renderer.render(
|
||||
device,
|
||||
queue,
|
||||
ctx.view, // the target view (already cleared to the clear color)
|
||||
ctx.size, // (width, height) in physical pixels
|
||||
&camera,
|
||||
&view, // the camera's world transform
|
||||
&Lighting::default(),
|
||||
&[
|
||||
RenderObject { mesh: &gpu_plane, material: Material::diffuse(Color::WHITE), transform: ground },
|
||||
RenderObject { mesh: &gpu_cube, material: Material::diffuse(Color::RED), transform: spin },
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
The color attachment is **loaded, not cleared**, so whatever cleared the surface
|
||||
beforehand (the window's clear color from Stage 2, or a `clear_view` call) shows
|
||||
through as the background. The depth buffer is owned by the renderer, resized to
|
||||
match the target, and cleared to `1.0` every call.
|
||||
|
||||
See the full runnable example:
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin hello_mesh # spinning cube + sphere + ground
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
- **One pipeline, one pass.** Geometry is drawn front-to-back-agnostic; a
|
||||
`Depth32Float` depth buffer with `Less` compare resolves occlusion, so draw
|
||||
order does not affect the result.
|
||||
- **Per-object data via dynamic uniform offsets.** Globals (view-projection,
|
||||
camera position, light) live in one uniform buffer (bind group 0). Each
|
||||
object's model matrix, normal matrix, and material live in a second uniform
|
||||
buffer addressed with a dynamic offset (bind group 1), so an arbitrary number
|
||||
of objects draw from one buffer that grows as needed.
|
||||
- **PBR-lite shading.** [`shaders/lit.wgsl`](../engine/src/render/shaders/lit.wgsl)
|
||||
does Lambert diffuse + ambient + a Blinn-Phong specular term whose sharpness
|
||||
comes from `roughness` and whose color comes from `metallic`. It outputs linear
|
||||
color; an sRGB surface converts on write.
|
||||
|
||||
## In the editor
|
||||
|
||||
The editor renders the active scene in a 3D viewport beneath its egui panels.
|
||||
Entities become visible by carrying a `MeshRenderer` component (`oxide_engine::render`):
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
// Make an entity render a cube with a custom material.
|
||||
let e = scene.spawn("crate", Transform::from_translation(Vec3::new(2.0, 0.5, 0.0)));
|
||||
scene.world_mut().insert_one(
|
||||
e,
|
||||
MeshRenderer::with_material(PrimitiveShape::Cube, Material::diffuse(Color::RED)),
|
||||
).unwrap();
|
||||
```
|
||||
|
||||
`MeshRenderer` names a built-in `PrimitiveShape` (cube/sphere/plane) rather than
|
||||
embedding geometry, so it is tiny and serializable (RON) — editable from both the
|
||||
inspector and, later, scripts/AI agents. The editor caches one GPU mesh per shape
|
||||
and draws every `MeshRenderer` entity through the `ForwardRenderer`, with an
|
||||
orbit camera (drag to orbit, right-drag to pan, scroll to zoom).
|
||||
|
||||
Entities are selected by **clicking them in the viewport** (a ray is cast against
|
||||
each renderable's world-space bounds) or from the hierarchy panel. The inspector
|
||||
edits the selection's **transform** (position, rotation as euler degrees, and
|
||||
scale) and its **material** (albedo / metallic / roughness — roughness controls
|
||||
specular-highlight sharpness, most visible on glossy/metallic surfaces).
|
||||
|
||||
## Testing
|
||||
|
||||
The window/viewport halves need a human eye, but the render path itself is
|
||||
verified headlessly (`tests/` `stage4`): render to an offscreen texture and read
|
||||
the pixels back to assert that lit geometry appears, the background shows through
|
||||
elsewhere, and a near object occludes a farther one through the depth buffer.
|
||||
Camera projection/view math has unit tests in `render/camera.rs`.
|
||||
|
||||
See also: [render-context.md](render-context.md) (surface/clear loop),
|
||||
[conventions.md](conventions.md) (handedness, color space), [scene.md](scene.md)
|
||||
(transforms and the hierarchy that feeds object placement).
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
# Scene Graph & Entity System
|
||||
|
||||
The `oxide_engine::scene` module is the world model every later system plugs
|
||||
into. It pairs a lightweight ECS ([`hecs`](https://docs.rs/hecs)) with a
|
||||
parent/child [`Transform`](math.md) hierarchy, so entities can hold arbitrary
|
||||
components *and* live in a spatial tree.
|
||||
|
||||
This document is the usage reference for the module as delivered in **Stage 3**.
|
||||
For the math types it builds on, see [math.md](math.md); for coordinate and
|
||||
units conventions, see [conventions.md](conventions.md).
|
||||
|
||||
## Importing
|
||||
|
||||
```rust
|
||||
use oxide_engine::scene::{Scene, Node, Entity, DespawnPolicy, SceneError};
|
||||
// or, for the common types, via the prelude:
|
||||
use oxide_engine::prelude::*; // Scene, Node, Entity, DespawnPolicy, SceneError, Transform, …
|
||||
```
|
||||
|
||||
The full ECS is re-exported as `oxide_engine::hecs` so you share one copy of
|
||||
`Entity` and the query API with the engine.
|
||||
|
||||
## Mental model
|
||||
|
||||
- An **entity** is a `hecs::Entity` handle — a small `Copy` id.
|
||||
- Every entity created through the scene carries a [`Node`](#node) (name +
|
||||
enabled flag) and a **local** [`Transform`](math.md).
|
||||
- The **hierarchy** (which entity parents which) is owned by the `Scene`, not
|
||||
stored as components. This keeps child ordering deterministic and makes
|
||||
reparenting cheap.
|
||||
- A **local** transform is what you author. A **world** transform is the local
|
||||
composed with every ancestor: `world = parent_world * local`. The scene
|
||||
resolves these on demand; it does not cache them.
|
||||
|
||||
| Type | Role |
|
||||
|------|------|
|
||||
| [`Scene`](#scene) | Owns entities + hierarchy; spawn, despawn, reparent, query, resolve transforms |
|
||||
| [`Node`](#node) | Per-entity metadata: `name`, `enabled` |
|
||||
| [`DespawnPolicy`](#despawning) | Whether despawn takes the subtree or detaches children |
|
||||
| [`SceneError`](#errors) | Reparent / (de)serialization failures |
|
||||
|
||||
---
|
||||
|
||||
## `Scene`
|
||||
|
||||
### Building a hierarchy
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
|
||||
let mut scene = Scene::new();
|
||||
|
||||
// A root entity (no parent).
|
||||
let sun = scene.spawn("sun", Transform::IDENTITY);
|
||||
|
||||
// Children. `spawn_child` panics if the parent is not a live entity.
|
||||
let planet = scene.spawn_child(
|
||||
sun,
|
||||
"planet",
|
||||
Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)),
|
||||
);
|
||||
let moon = scene.spawn_child(
|
||||
planet,
|
||||
"moon",
|
||||
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
|
||||
);
|
||||
```
|
||||
|
||||
`spawn`/`spawn_child` take anything that converts into a `Node`, so a bare
|
||||
`&str` works as a name (`"planet"` ≡ `Node::new("planet")`); pass a `Node`
|
||||
directly when you need to set `enabled`.
|
||||
|
||||
### Resolving world transforms
|
||||
|
||||
```rust
|
||||
// One entity (walks up the parent chain):
|
||||
let moon_world = scene.world_transform(moon).unwrap();
|
||||
|
||||
// Every entity at once (single top-down pass — prefer this in bulk):
|
||||
let worlds = scene.world_transforms(); // HashMap<Entity, Transform>
|
||||
```
|
||||
|
||||
`world_transforms()` is the path the renderer will use; it resolves a
|
||||
10,000-entity, 5-level scene in well under a millisecond (see the
|
||||
`world_transforms_10k_depth5` benchmark).
|
||||
|
||||
### Reparenting
|
||||
|
||||
```rust
|
||||
scene.set_parent(moon, Some(sun))?; // moon now orbits the sun directly
|
||||
scene.set_parent(moon, None)?; // moon becomes a root
|
||||
```
|
||||
|
||||
Reparenting preserves the **local** transform (it does not compensate to keep
|
||||
the world transform fixed). Cycles are rejected: parenting an entity to itself
|
||||
or to one of its descendants returns [`SceneError::WouldCycle`], leaving the
|
||||
hierarchy untouched.
|
||||
|
||||
### Despawning
|
||||
|
||||
```rust
|
||||
use oxide_engine::scene::DespawnPolicy;
|
||||
|
||||
// Remove the entity and its entire subtree:
|
||||
scene.despawn(planet, DespawnPolicy::Recursive);
|
||||
|
||||
// Remove only the entity; its children move up to its parent (or become roots
|
||||
// if it was a root):
|
||||
scene.despawn(planet, DespawnPolicy::DetachChildren);
|
||||
```
|
||||
|
||||
### Editing nodes
|
||||
|
||||
```rust
|
||||
scene.set_name(planet, "earth");
|
||||
scene.set_enabled(moon, false); // later systems skip disabled subtrees
|
||||
scene.set_local_transform(moon, Transform::IDENTITY);
|
||||
|
||||
let name = scene.name(planet); // Option<String>
|
||||
let on = scene.is_enabled(moon); // Option<bool>
|
||||
let local = scene.local_transform(moon); // Option<Transform>
|
||||
```
|
||||
|
||||
### Querying
|
||||
|
||||
```rust
|
||||
for &root in scene.roots() { /* … */ }
|
||||
for &child in scene.children(planet) { /* … */ }
|
||||
let parent = scene.parent(moon); // Option<Entity>
|
||||
let n = scene.len();
|
||||
```
|
||||
|
||||
### Extra components (it's a real ECS)
|
||||
|
||||
Entities are full `hecs` entities, so later stages attach their own components
|
||||
(meshes, rigid bodies, …) alongside the `Node`/`Transform`:
|
||||
|
||||
```rust
|
||||
scene.world_mut().insert_one(planet, /* e.g. */ 0u32).unwrap();
|
||||
let value = scene.get::<u32>(planet); // Option<hecs::Ref<u32>>
|
||||
```
|
||||
|
||||
Use `world()` for read-only queries and `world_mut()` for adding/removing
|
||||
*non-hierarchy* components. Drive lifecycle and parenting through the `Scene`
|
||||
methods so the hierarchy bookkeeping stays consistent — spawning or despawning
|
||||
directly on the world bypasses it.
|
||||
|
||||
---
|
||||
|
||||
## `Node`
|
||||
|
||||
```rust
|
||||
pub struct Node {
|
||||
pub name: String, // display name; not required to be unique
|
||||
pub enabled: bool, // honored by later systems, not by transform resolution
|
||||
}
|
||||
```
|
||||
|
||||
`Node::new(name)` builds an enabled node. `enabled` is a declaration of intent:
|
||||
Stage 3 only stores and toggles it; rendering/physics/audio will skip disabled
|
||||
subtrees in later stages. It deliberately does **not** affect
|
||||
`world_transform`, which is purely geometric.
|
||||
|
||||
---
|
||||
|
||||
## Serialization
|
||||
|
||||
A scene round-trips through RON. Because `hecs::Entity` handles are not stable
|
||||
across a save/load, the scene is flattened to an indexed node list in a
|
||||
deterministic pre-order walk, so serialize → deserialize → serialize is
|
||||
byte-for-byte stable.
|
||||
|
||||
```rust
|
||||
let ron: String = scene.to_ron()?;
|
||||
let restored = Scene::from_ron(&ron)?;
|
||||
assert_eq!(ron, restored.to_ron()?); // identical
|
||||
```
|
||||
|
||||
Corrupt input (out-of-range child indices, a node listed as both root and
|
||||
child) is rejected with [`SceneError::Deserialize`].
|
||||
|
||||
---
|
||||
|
||||
## Errors
|
||||
|
||||
```rust
|
||||
pub enum SceneError {
|
||||
NoSuchEntity, // operation referenced a dead entity
|
||||
WouldCycle, // reparent would make an entity its own ancestor
|
||||
Serialize(String), // encoding to RON failed
|
||||
Deserialize(String), // decoding failed or data was inconsistent
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example
|
||||
|
||||
A complete, runnable tour lives in
|
||||
[`examples/src/bin/scene_basic.rs`](../examples/src/bin/scene_basic.rs):
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin scene_basic
|
||||
```
|
||||
|
||||
It builds a sun/planet/moon hierarchy, prints local vs. world transforms,
|
||||
reparents a node, round-trips through RON, and despawns with a detach policy.
|
||||
|
||||
## In the editor
|
||||
|
||||
`oxide-editor` renders a **Scene Hierarchy** panel (left) and an **Inspector**
|
||||
(right) over the viewport, driving this same API: select a node, rename it,
|
||||
toggle its `enabled` flag, reparent it via the Inspector's parent dropdown, and
|
||||
add/delete nodes from the toolbar. The egui integration is editor-only — the
|
||||
engine exposes a generic post-clear draw hook ([`App::render`]) and keeps egui
|
||||
out of its own dependency tree.
|
||||
|
||||
[`App::render`]: ../engine/src/window/app.rs
|
||||
|
||||
## Design notes
|
||||
|
||||
- **Why hierarchy outside the ECS?** Storing `Parent`/`Children` as components
|
||||
is idiomatic but makes ordered iteration and reparenting awkward (archetype
|
||||
moves, borrow juggling) and gives no ordering guarantee. Keeping the tree in
|
||||
the `Scene` yields deterministic child order — which serialization and the
|
||||
editor both rely on — and O(1) link edits. The ECS still owns all entity
|
||||
*data*.
|
||||
- **World transforms are resolved, not stored.** There is no dirty-flag cache
|
||||
yet; `world_transforms()` recomputes in one pass. A cache can be added later
|
||||
behind the same API without changing callers.
|
||||
- **Despawn policies** map onto the two things callers actually want: delete a
|
||||
whole subtree, or remove one node and keep its children.
|
||||
@@ -0,0 +1,332 @@
|
||||
# Scripting (`oxide-script`, rhai) — Stage 10
|
||||
|
||||
The scripting module makes game logic live in **watched `.rhai` scripts** that
|
||||
hot-reload while the editor runs. It is a feature-gated module built on
|
||||
[`rhai`](https://rhai.rs) — an embeddable, sandboxed, Rust-friendly scripting
|
||||
language — and plugs into the engine through the Stage-5 module system exactly
|
||||
like [physics](physics.md): add [`ScriptModule`](#scriptmodule) to an `App` and
|
||||
the [`Script`](#the-script-component) component becomes live.
|
||||
|
||||
> **Status.** This page tracks Stage 10 as it lands piece by piece. **Done so
|
||||
> far:** the component data model, the script asset + `.rhai` loader, the engine
|
||||
> wrapper, the module wiring, the per-frame lifecycle (`init` / `update(dt)`)
|
||||
> driven by the [`ScriptHost`](#the-scripthost-lifecycle), the engine API
|
||||
> (`Vec3` + ambient transform functions) scripts use to read/write their
|
||||
> entity's `Transform`, **headless [live reload](#live-reload)** (edit a
|
||||
> `.rhai` file → the running script recompiles, no restart), and **[editor
|
||||
> integration](#editor-integration)** (Script is addable in the inspector; Play
|
||||
> runs scripts and live-reload reaches a *playing* scene), and **[error/output
|
||||
> surfacing](#errors-and-output-in-the-console)** (a paused script's error and
|
||||
> its `print` output show in the editor Console panel), and a **[command
|
||||
> terminal](#the-command-terminal)** in that panel, plus an **[interactive PTY
|
||||
> terminal](#interactive-terminal-pty)** that runs shells / TUIs / AI-agent CLIs
|
||||
> like `claude`. **Remaining for the stage:** a richer script API (spawn/despawn
|
||||
> + component add/edit, beyond `Transform`).
|
||||
|
||||
## The model: ECS is the source of truth
|
||||
|
||||
As with physics, the **ECS owns the truth**. An entity opts into scripting with
|
||||
one serializable, reflected component — [`Script`](#the-script-component) — that
|
||||
carries only *authoring* inputs (which script, enabled or not). The script's
|
||||
behaviour is **not** stored on the component:
|
||||
|
||||
- The source lives on disk as a `.rhai` file, loaded as a
|
||||
[`ScriptAsset`](#scriptasset-the-loaded-source) (kept as plain text so a live
|
||||
edit just recompiles).
|
||||
- The compiled AST and any per-entity runtime state live in the host (a later
|
||||
piece), keyed by entity.
|
||||
|
||||
Because the authored component carries no runtime state, **play-mode
|
||||
snapshot/restore works for free**: Stop reverts the authored `Script` components
|
||||
and the next Play recompiles fresh.
|
||||
|
||||
## The `Script` component
|
||||
|
||||
```rust
|
||||
use oxide_engine::asset::AssetRef;
|
||||
use oxide_script::{Script, ScriptAsset};
|
||||
|
||||
// Empty + disabled by default; `new` points at a source and enables it.
|
||||
let script = Script::new(AssetRef::new(uid)); // uid from the asset database
|
||||
assert!(script.enabled);
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `source` | `AssetRef<ScriptAsset>` | Which `.rhai` script the entity runs. Empty until assigned. |
|
||||
| `enabled` | `bool` | Whether the script runs; clear to suspend it without detaching. |
|
||||
|
||||
`source` is an [`AssetRef<T>`](assets.md), not a live `Handle<T>`, so it is
|
||||
serializable and stable across runs; the inspector recognises the
|
||||
`AssetRef<ScriptAsset>` spelling and offers a picker filtered to the `scripts/`
|
||||
folder (`AssetKind::Script`). The component derives `Reflect`, so it is
|
||||
dual-editable from the inspector, from scripts, and from external tools with no
|
||||
per-type editor code.
|
||||
|
||||
## `ScriptAsset`: the loaded source
|
||||
|
||||
A `ScriptAsset` is just the script's source text plus a diagnostic name (the
|
||||
file stem). It is deliberately inert — holding *source*, not behaviour — so the
|
||||
same file can be recompiled on every live reload with no engine-specific data
|
||||
baked into the asset cache.
|
||||
|
||||
```rust
|
||||
use oxide_script::ScriptAsset;
|
||||
|
||||
let asset = ScriptAsset::from_source("spin", "let t = 0.0;");
|
||||
assert_eq!(asset.name, "spin");
|
||||
```
|
||||
|
||||
The `ScriptLoader` reads `.rhai` files; it is registered by `ScriptModule`, so
|
||||
`assets.load::<ScriptAsset>("scripts/spin.rhai")` works once the module is added.
|
||||
`.rhai` files map to the new `AssetKind::Script` (folder `scripts/`).
|
||||
|
||||
## The `ScriptEngine` wrapper
|
||||
|
||||
`ScriptEngine` owns one configured `rhai` interpreter the host reuses to compile
|
||||
and run every script, so sandbox configuration lives in one place:
|
||||
|
||||
- `print` / `debug` output is routed to the `log` crate (so the editor console
|
||||
can surface it rather than leaking to stdout);
|
||||
- an operation cap (`set_max_operations`) turns a runaway loop into a **runtime
|
||||
error** instead of hanging the editor.
|
||||
|
||||
```rust
|
||||
use oxide_script::{ScriptAsset, ScriptEngine};
|
||||
|
||||
let engine = ScriptEngine::new();
|
||||
let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);");
|
||||
let compiled = engine.compile(&asset)?; // -> CompiledScript (reusable AST)
|
||||
engine.run(&compiled)?; // evaluate the top level
|
||||
```
|
||||
|
||||
Errors are a `thiserror` enum, [`ScriptError`], with two variants that both name
|
||||
the offending script so the console can attribute the failure:
|
||||
|
||||
- `ScriptError::Compile` — the source failed to parse/compile;
|
||||
- `ScriptError::Runtime` — it compiled but raised an error (or hit the operation
|
||||
cap) while running.
|
||||
|
||||
A runtime error is **returned, never panicked**, so the host can pause just that
|
||||
one script rather than crash the editor — the foundation for Stage 10's
|
||||
error-isolation goal.
|
||||
|
||||
## `ScriptModule`
|
||||
|
||||
```rust
|
||||
use oxide_engine::app::App;
|
||||
use oxide_script::ScriptModule;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_module(ScriptModule);
|
||||
assert!(app.has_module("script"));
|
||||
assert!(app.types.is_registered("Script"));
|
||||
```
|
||||
|
||||
`ScriptModule::build` registers the `Script` component type for reflection
|
||||
(making it dual-editable and snapshot-captured), installs the `.rhai`
|
||||
`ScriptLoader`, inserts the [`ScriptHost`](#the-scripthost-lifecycle), and adds
|
||||
the `run_scripts` system on the `Update` schedule (after `FixedUpdate`, so
|
||||
scripts observe post-physics poses). Removing the module drops everything it
|
||||
contributed, as for any module.
|
||||
|
||||
## The `ScriptHost` (lifecycle)
|
||||
|
||||
`ScriptHost` is the scripting counterpart to physics' `PhysicsWorld`: a transient
|
||||
`App` resource holding the **per-entity runtime state** — the compiled AST and a
|
||||
persistent `rhai` `Scope` — that the authored `Script` component deliberately
|
||||
does not. The `run_scripts` system drives it each frame:
|
||||
|
||||
1. find every entity with an **enabled** `Script` that names a source;
|
||||
2. resolve each one's `.rhai` text through the `AssetDatabase` + `AssetServer`;
|
||||
3. **(re)compile + `start`** any script that is new or whose source text changed
|
||||
(this content check is the hook live reload builds on);
|
||||
4. stage the entity's `Transform` into the engine, call `update(dt)`, and write
|
||||
the (possibly mutated) transform back to the scene.
|
||||
|
||||
Because runtime state lives in the host keyed by entity — never on the component
|
||||
— play-mode snapshot/restore is unaffected. A script that fails to compile or
|
||||
raises a runtime error is **paused** (its error remembered, reported via
|
||||
`ScriptHost::error_of`) rather than retried every frame or allowed to crash the
|
||||
host — the start of Stage 10's error-isolation goal.
|
||||
|
||||
## Live reload
|
||||
|
||||
Editing a script while the app runs takes effect with **no restart**. The host
|
||||
keeps each running script's `Handle<ScriptAsset>` alive, so the asset stays in
|
||||
the server's cache. When the Stage-6 file watcher sees a `.rhai` file change it
|
||||
calls
|
||||
[`reload_changed_assets`](file-watching.md), which reruns the loader **in place**
|
||||
on the live handle; the next frame the host reads the new source through that
|
||||
handle, sees it differs from what it compiled, and recompiles + restarts that one
|
||||
script. The entity keeps its current transform and the rest of the scene is
|
||||
untouched.
|
||||
|
||||
`examples/script_spin` proves this headlessly: it spins an entity at 1 rad/s,
|
||||
rewrites the script to 3 rad/s, reloads it the way the watcher does, and the spin
|
||||
rate jumps mid-run while the orientation carries over.
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin script_spin
|
||||
```
|
||||
|
||||
> Wiring this into the editor's **play loop** (so editing a script in an external
|
||||
> editor or via an AI agent updates a *playing* scene) is a following piece; the
|
||||
> reload mechanism itself is done and tested.
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
A script may define either or both of these functions; top-level statements run
|
||||
once at start (a constructor for defining functions and one-shot setup):
|
||||
|
||||
```rhai
|
||||
// scripts/spin.rhai — rotate this entity around Y at a constant rate.
|
||||
let speed = 1.5; // top-level state, set once at start
|
||||
|
||||
fn init() { // optional: called once, after the top level
|
||||
print("spin starting");
|
||||
}
|
||||
|
||||
fn update(dt) { // optional: called every frame with the frame delta
|
||||
rotate_y(dt * 1.5);
|
||||
}
|
||||
```
|
||||
|
||||
| Hook | When | Signature |
|
||||
|------|------|-----------|
|
||||
| top level | once, when the script (re)starts | statements at file scope |
|
||||
| `init()` | once, right after the top level | `fn init()` |
|
||||
| `update(dt)` | every frame | `fn update(dt)` — `dt` is seconds |
|
||||
|
||||
## Editor integration
|
||||
|
||||
Scripting plugs into the editor the same way physics does:
|
||||
|
||||
- **Add Component.** `register_builtin_types` registers `Script` as an *addable*
|
||||
reflected component, so it appears in the inspector's Add Component menu and is
|
||||
rendered generically from its fields. The `source` field is an
|
||||
`AssetRef<ScriptAsset>`, which the inspector shows as an asset picker filtered
|
||||
to the project's `scripts/` folder (`AssetKind::Script`).
|
||||
- **Play loop.** When Play starts, the editor's play `App` adds `ScriptModule`
|
||||
alongside `PhysicsModule`, **shares the editor's `AssetServer`**, and is handed
|
||||
a snapshot of the project `AssetDatabase`. Sharing the server is what lets the
|
||||
file watcher's in-place reloads — which target the editor server — reach a
|
||||
**playing** scene: edit a `.rhai` file (by hand, an external editor, or an AI
|
||||
agent) and the running script recompiles without leaving Play.
|
||||
- **Snapshot/restore.** Because `Script` is a reflected component, the play-mode
|
||||
snapshot captures it; a script attached or detached *during* play is reverted
|
||||
on Stop, like any other component.
|
||||
|
||||
## Errors and output in the Console
|
||||
|
||||
A script that fails to compile or raises a runtime error is **paused** — it stops
|
||||
running but the editor stays alive (the error is caught, never panicked). The
|
||||
host logs the failure once via `log::warn!(target: "oxide_script", …)` and
|
||||
remembers it (`ScriptHost::error_of`), so it is not retried until the source
|
||||
changes.
|
||||
|
||||
The editor's **Console panel** captures the `log` stream into a ring buffer and
|
||||
renders it, coloured by severity. Because script `print`/`debug` and the
|
||||
"script paused: …" errors all flow through `log` under the `oxide_script` target,
|
||||
they appear in the Console automatically — so you see a script's output and its
|
||||
failures without leaving the editor.
|
||||
|
||||
## The command terminal
|
||||
|
||||
The Console panel doubles as a **command terminal**: a `$` prompt runs a shell
|
||||
command (`sh -c`) with the working directory set to the open project's root, and
|
||||
its stdout/stderr stream back into the same panel line by line as they arrive
|
||||
(stdout at info, stderr at warn, plus the echoed command and an exit-status
|
||||
line). A long-running command — a build, a watcher, an AI-agent CLI — streams
|
||||
rather than blocking the editor: reader threads push each line to the shared
|
||||
console buffer and the panel re-renders next frame.
|
||||
|
||||
This is the surface for non-interactive dev tools. Running arbitrary commands
|
||||
from the editor is intentional (a developer tool, compiled out of an exported
|
||||
game).
|
||||
|
||||
### Interactive terminal (PTY)
|
||||
|
||||
The command console pipes output and can't run programs that need a real
|
||||
terminal. The separate **Terminal panel** can: it opens a pseudo-terminal with
|
||||
[`portable-pty`] (Linux now, Windows later), parses the program's byte stream
|
||||
with [`vt100`] into a screen grid, renders that grid in egui, and routes
|
||||
keystrokes back — so it runs **interactive / full-screen programs**: a shell, a
|
||||
REPL, `vim`, or an **AI-agent CLI like `claude`**. Click *Shell* to start your
|
||||
`$SHELL` in the project directory, then run whatever you need inside it
|
||||
(`claude`, an editor, a build watcher). Tab, the arrow keys, and Escape are
|
||||
delivered to the program (not used for egui focus navigation) while the panel is
|
||||
focused, so completion, history, and full-screen apps work. Sessions are
|
||||
**tabbed** — `+ Shell` opens another, each tab has a close button, and a tab
|
||||
**auto-closes when its program exits** (type `exit` and the tab disappears).
|
||||
This is what hosts agents that edit the watched scripts live — their edits flow
|
||||
back through [live reload](#live-reload).
|
||||
|
||||
The two pieces compose: use the **Console** for builds/git/log output, the
|
||||
**Terminal** for interactive sessions.
|
||||
|
||||
[`portable-pty`]: https://docs.rs/portable-pty
|
||||
[`vt100`]: https://docs.rs/vt100
|
||||
|
||||
## The engine API scripts call
|
||||
|
||||
A script does not get a raw ECS pointer. The host stages the active entity's
|
||||
`Transform` into a shared context before each call; the script reads and mutates
|
||||
it through **ambient functions**, and the host writes the result back. This is
|
||||
the `Transform` half of dual-editability: a script and the inspector edit the
|
||||
**same** transform.
|
||||
|
||||
| Function | Effect |
|
||||
|----------|--------|
|
||||
| `position() -> Vec3` | the entity's local translation |
|
||||
| `set_position(Vec3)` | set the translation |
|
||||
| `translate(Vec3)` / `translate(x, y, z)` | add to the translation |
|
||||
| `scale() -> Vec3` / `set_scale(Vec3)` | get/set the local scale |
|
||||
| `rotate_x/rotate_y/rotate_z(radians)` | spin about an axis (accumulates) |
|
||||
| `dt() -> f32` | the current frame delta (also passed to `update`) |
|
||||
|
||||
The `Vec3` type is registered with `vec3(x, y, z)` / `vec3()`, `.x`/`.y`/`.z`
|
||||
get/set, `+ - *` (and scalar `*`), `length()`, `normalize()`, and `to_string()`.
|
||||
The `rhai` float type is configured to `f32` (the `f32_float` feature), so engine
|
||||
math values bridge into scripts with no casts.
|
||||
|
||||
### Spawning entities and editing components
|
||||
|
||||
Beyond its own transform, a script can mutate the whole scene graph: spawn and
|
||||
despawn entities, and add, edit, or remove **any registered component** on any
|
||||
entity. The same "stage in, read back out" discipline applies — a script cannot
|
||||
borrow the ECS directly (the `rhai` functions must be `Send + Sync`), so each of
|
||||
these calls **buffers a command** that the host drains and applies against the
|
||||
scene + reflection registry after the script returns. Component edits go through
|
||||
**RON**, so a script authors data exactly like the inspector or an AI agent does
|
||||
— the same dual-editable representation.
|
||||
|
||||
| Function | Effect |
|
||||
|----------|--------|
|
||||
| `entity() -> Entity` | the entity this script runs on |
|
||||
| `spawn_entity() -> Entity` / `spawn_entity(name)` | create a root entity, returns a handle usable immediately |
|
||||
| `despawn(Entity)` | remove an entity (and its subtree) |
|
||||
| `add_component(Entity, type_name)` | add a default-constructed component if the type is *addable* and absent |
|
||||
| `set_component(Entity, type_name, ron)` | insert or replace a component from its RON form |
|
||||
| `remove_component(Entity, type_name)` | remove the named component if present |
|
||||
|
||||
`spawn_entity` returns a **provisional** `Entity` handle: the entity does not
|
||||
exist in the ECS yet, but the script can configure it in the same frame
|
||||
(`set_component(e, …)`, `despawn(e)`) — the host resolves the provisional id to
|
||||
the real entity when it applies the buffered commands, in issue order. (`spawn`
|
||||
is a reserved word in `rhai`, hence the longer name.)
|
||||
|
||||
`type_name` is the name the component was registered under (e.g. `"MeshRenderer"`,
|
||||
`"RigidBody"`, or your own `register_type::<T>("…")`). A command that names an
|
||||
unregistered type, or whose RON fails to parse, is logged to the Console and
|
||||
skipped — one bad call never aborts the rest or crashes the host.
|
||||
|
||||
```rust
|
||||
// Spawn a pickup and configure it in one frame:
|
||||
fn init() {
|
||||
let pickup = spawn_entity("Coin");
|
||||
set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)");
|
||||
}
|
||||
```
|
||||
|
||||
[`ScriptError`]: #the-scriptengine-wrapper
|
||||
@@ -0,0 +1,70 @@
|
||||
# Settings & Preferences Framework
|
||||
|
||||
`oxide_engine::settings` is the engine's unified, serialized configuration store.
|
||||
The engine, the editor, and every module register typed **sections**; the
|
||||
framework persists them all to RON and restores them — without any central code
|
||||
knowing the sections' shapes. It is the backbone of the editor's Preferences
|
||||
window and of per-module settings.
|
||||
|
||||
## Sections
|
||||
|
||||
A section is any plain `serde`-serializable struct with a `Default`. Register it
|
||||
once under a name and the store owns a typed instance:
|
||||
|
||||
```rust
|
||||
use oxide_engine::settings::Settings;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct EditorPrefs { theme: String, grid: bool }
|
||||
|
||||
let mut settings = Settings::new();
|
||||
settings.register::<EditorPrefs>("editor");
|
||||
|
||||
// Typed read/write.
|
||||
settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
assert_eq!(settings.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
```
|
||||
|
||||
`set` replaces a section's value (only if the registered type matches), and
|
||||
`reset` returns it to `Default`. Accessing a section as the wrong type returns
|
||||
`None` rather than panicking.
|
||||
|
||||
## Persisting
|
||||
|
||||
Every section serializes to a `name → RON` map via `export`, and `import` loads
|
||||
matching sections back. This map is exactly the shape a
|
||||
[`Project`](projects.md) stores, so per-project settings round-trip through the
|
||||
project file:
|
||||
|
||||
```rust
|
||||
# use oxide_engine::settings::Settings;
|
||||
# use serde::{Serialize, Deserialize};
|
||||
# #[derive(Serialize, Deserialize, Default)] struct EditorPrefs { theme: String }
|
||||
# let mut settings = Settings::new();
|
||||
# settings.register::<EditorPrefs>("editor");
|
||||
let saved = settings.export(); // BTreeMap<String, String>
|
||||
|
||||
let mut restored = Settings::new();
|
||||
restored.register::<EditorPrefs>("editor");
|
||||
restored.import(&saved); // matching sections restored
|
||||
```
|
||||
|
||||
`import` is deliberately lenient: an **unknown** section (e.g. one owned by a
|
||||
disabled module) is ignored, and a **malformed** section is skipped, leaving its
|
||||
current value. This means a project saved with a module enabled still opens
|
||||
cleanly with that module disabled, and vice-versa.
|
||||
|
||||
## How the layers fit together
|
||||
|
||||
| Scope | Lives where | Persisted to |
|
||||
|-------|-------------|--------------|
|
||||
| Engine preferences (render/quality defaults) | a `Settings` section | global prefs file / project |
|
||||
| Editor preferences (theme, layout, shortcuts) | a `Settings` section | global editor prefs file |
|
||||
| Per-module settings | each module registers a section | the [project](projects.md) it's enabled in |
|
||||
|
||||
`Project` stores the per-project subset (`set_settings_section` /
|
||||
`settings_section` hold the same RON blobs `export`/`import` produce). The editor
|
||||
keeps its global preferences in a separate file using the same `Settings` API.
|
||||
|
||||
[`Settings`]: ../engine/src/settings.rs
|
||||
+860
@@ -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 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.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Windowing & the Application Loop
|
||||
|
||||
Stage 2 reference for `oxide_engine::window` — opening a window, running the
|
||||
event loop, and receiving raw input. For what happens *inside* a frame (GPU
|
||||
setup, clearing, resize handling) see [render-context.md](render-context.md).
|
||||
|
||||
## Overview
|
||||
|
||||
The window module wraps [`winit`](https://docs.rs/winit) so applications never
|
||||
talk to the event loop directly. You implement the `WindowApp` trait, hand it
|
||||
to `run()` together with a `WindowConfig`, and the engine:
|
||||
|
||||
1. creates the window and the GPU [`RenderContext`](render-context.md),
|
||||
2. calls `WindowApp::init` once,
|
||||
3. then loops: forwards every raw window event to `WindowApp::event`, calls
|
||||
`WindowApp::update` once per frame, and clears + presents the surface.
|
||||
|
||||
The loop runs in `Poll` mode (continuous rendering, as a game expects), not
|
||||
event-driven `Wait` mode (as a desktop utility would use).
|
||||
|
||||
> **Stage 6 rename.** This trait was originally `App`. Stage 6 renamed it to
|
||||
> `WindowApp` so the engine's [`oxide_engine::app::App`](modules.md) container
|
||||
> (scene, assets, scheduled systems) could live in the prelude unambiguously.
|
||||
> The two cover different roles: this trait is the per-frame window/event
|
||||
> handler; the container is engine state your handler typically wraps around.
|
||||
|
||||
## Minimal application
|
||||
|
||||
```rust
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::window::event::{Key, NamedKey, ElementState, WindowEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MyApp;
|
||||
|
||||
impl WindowApp for MyApp {
|
||||
fn init(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
ctx.set_clear_color(Color::rgb(0.39, 0.58, 0.93));
|
||||
}
|
||||
|
||||
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
|
||||
if let WindowEvent::KeyboardInput { event: key, .. } = event {
|
||||
if key.state == ElementState::Pressed
|
||||
&& key.logical_key == Key::Named(NamedKey::Escape)
|
||||
{
|
||||
ctx.request_exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &mut AppCtx<'_>) {
|
||||
let _seconds_since_last_frame = ctx.dt;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
run(WindowConfig::default(), MyApp)
|
||||
}
|
||||
```
|
||||
|
||||
`run()` blocks the calling thread until the app exits — an OS requirement (the
|
||||
event loop must own the main thread), not an engine choice.
|
||||
|
||||
## `WindowConfig`
|
||||
|
||||
Initial window settings. All fields are plain data:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|-------|---------|---------|
|
||||
| `title` | `"Oxide"` | Window title |
|
||||
| `width`, `height` | 1280 × 720 | Initial inner size, logical pixels |
|
||||
| `resizable` | `true` | Whether the user can resize |
|
||||
| `clear_color` | `Color::BLACK` | Initial per-frame clear color |
|
||||
|
||||
## The `WindowApp` trait
|
||||
|
||||
Three callbacks, all optional (empty default bodies):
|
||||
|
||||
- **`init(ctx)`** — once, after the window and GPU exist, before the first
|
||||
frame. Set the title, clear color, load resources.
|
||||
- **`event(ctx, event)`** — for *every* raw `WindowEvent`, including ones the
|
||||
engine also reacts to (close request, resize), so apps can observe
|
||||
everything. Stage 2 exposes events untranslated; the
|
||||
[Stage-7 input system](input.md) layers per-key edge detection and
|
||||
remappable named actions on top, surfaced through `ctx.input()`.
|
||||
- **`update(ctx)`** — once per frame, before the frame is cleared and
|
||||
presented. `ctx.dt` is the seconds elapsed since the previous frame (`0.0`
|
||||
on the first).
|
||||
|
||||
Per frame the order is: pending `event` calls → `update` → render.
|
||||
|
||||
## `AppCtx`
|
||||
|
||||
Every callback receives `&mut AppCtx`, the engine state an app may touch:
|
||||
|
||||
| Member | Purpose |
|
||||
|--------|---------|
|
||||
| `dt` | Frame delta time in seconds (field) |
|
||||
| `set_clear_color(color)` / `clear_color()` | Per-frame clear color; changes apply on the next frame |
|
||||
| `size()` | Current surface size in physical pixels |
|
||||
| `set_title(title)` | Change the window title |
|
||||
| `request_exit()` | Leave the event loop after the current callback |
|
||||
| `render()` | Direct access to the [`RenderContext`](render-context.md) |
|
||||
| `input()` | The per-frame [`InputState`](input.md) snapshot |
|
||||
|
||||
## Raw event types
|
||||
|
||||
`oxide_engine::window::event` re-exports the `winit` event vocabulary
|
||||
(`WindowEvent`, `KeyEvent`, `MouseButton`, `ElementState`, `KeyCode`,
|
||||
`PhysicalKey`, `Key`, `NamedKey`, `ModifiersState`, …) so applications don't
|
||||
need their own `winit` dependency. The whole crates are also available as
|
||||
`oxide_engine::winit` and `oxide_engine::wgpu` for anything not curated.
|
||||
|
||||
Two keyboard representations matter:
|
||||
|
||||
- `KeyEvent::physical_key` (`PhysicalKey::Code(KeyCode::KeyW)`) — the physical
|
||||
key position, layout-independent. Use for game-style controls.
|
||||
- `KeyEvent::logical_key` (`Key::Named(NamedKey::Escape)` or
|
||||
`Key::Character(…)`) — what the key means under the user's layout. Use for
|
||||
shortcuts and text.
|
||||
|
||||
## Engine-handled events
|
||||
|
||||
The runner reacts to these before forwarding them:
|
||||
|
||||
| Event | Engine behavior |
|
||||
|-------|-----------------|
|
||||
| `CloseRequested` | Exits the loop (apps can't veto it in Stage 2) |
|
||||
| `Resized` | Reconfigures the surface (see [render-context.md](render-context.md)) |
|
||||
| `RedrawRequested` | Computes `dt`, calls `update`, renders the frame |
|
||||
|
||||
Errors during window/GPU creation or rendering are returned from `run()`;
|
||||
winit callbacks can't propagate `Result`, so the runner stashes the first
|
||||
error and exits the loop.
|
||||
|
||||
## Trying it
|
||||
|
||||
```sh
|
||||
cargo run -p oxide-examples --bin hello_window
|
||||
```
|
||||
|
||||
Keys `1`–`5` switch clear-color presets, `Space` cycles, `Esc` quits; average
|
||||
FPS is logged once per second. The editor (`cargo run -p oxide-editor`) uses
|
||||
the same infrastructure and quits with `Ctrl+Q`.
|
||||
+2
-123
@@ -1,7 +1,5 @@
|
||||
//! Bundled editor assets — locating the shared `assets/` tree, seeding a new
|
||||
//! project's default content (currently the default UI font), and creating new
|
||||
//! script files from the built-in template (the inspector's "New Script"
|
||||
//! button).
|
||||
//! Bundled editor assets — locating the shared `assets/` tree and seeding a
|
||||
//! new project's default content (currently the default UI font).
|
||||
//!
|
||||
//! The editor ships a small set of shared assets (icons, the default UI font, …)
|
||||
//! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have
|
||||
@@ -93,79 +91,6 @@ pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The `.rhai` source the "New Script" button writes, personalised with the
|
||||
/// script's file stem so the Console output identifies which script speaks.
|
||||
///
|
||||
/// Kept to the two lifecycle hooks the wiki's Scripting page teaches first; the
|
||||
/// `update` body ships commented out so a freshly created script visibly runs
|
||||
/// (the `init` print) without moving anything until the author opts in.
|
||||
pub fn script_template(stem: &str) -> String {
|
||||
format!(
|
||||
r#"// {stem}.rhai — attached via a Script component.
|
||||
//
|
||||
// Top-level statements run once when the script (re)starts. `init()` runs
|
||||
// once after them; `update(dt)` runs every frame (dt = seconds).
|
||||
|
||||
fn init() {{
|
||||
print("{stem}: init");
|
||||
}}
|
||||
|
||||
fn update(dt) {{
|
||||
// e.g. rotate_y(dt * 1.5);
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Reduces a typed script name to a safe file stem: keeps ASCII alphanumerics,
|
||||
/// `-` and `_`, folds anything else (spaces, punctuation, Unicode) to `_`,
|
||||
/// collapses runs, trims the ends, and drops a trailing `.rhai` the user may
|
||||
/// have typed. An unusable input yields `"new_script"`.
|
||||
pub fn sanitize_script_stem(name: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
let trimmed = trimmed.strip_suffix(".rhai").unwrap_or(trimmed);
|
||||
let mut stem = String::with_capacity(trimmed.len());
|
||||
for c in trimmed.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
stem.push(c);
|
||||
} else if !stem.ends_with('_') {
|
||||
stem.push('_');
|
||||
}
|
||||
}
|
||||
let stem = stem.trim_matches('_');
|
||||
if stem.is_empty() {
|
||||
"new_script".to_owned()
|
||||
} else {
|
||||
stem.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new script file under `<assets_dir>/scripts/` from the template,
|
||||
/// returning its assets-relative path (e.g. `"scripts/my_script.rhai"`) for
|
||||
/// registration in the [`AssetDatabase`](oxide_engine::asset::AssetDatabase).
|
||||
///
|
||||
/// The desired name is [sanitized](sanitize_script_stem); a taken name gets a
|
||||
/// numeric suffix (`stem_2`, `stem_3`, …) instead of failing or overwriting,
|
||||
/// so the button always succeeds on a writable project.
|
||||
pub fn create_script_file(assets_dir: &Path, desired_name: &str) -> std::io::Result<String> {
|
||||
use oxide_engine::asset::AssetKind;
|
||||
|
||||
let stem = sanitize_script_stem(desired_name);
|
||||
let dir = assets_dir.join(AssetKind::Script.folder());
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let mut candidate = stem.clone();
|
||||
let mut n = 1;
|
||||
while dir.join(format!("{candidate}.rhai")).exists() {
|
||||
n += 1;
|
||||
candidate = format!("{stem}_{n}");
|
||||
}
|
||||
std::fs::write(
|
||||
dir.join(format!("{candidate}.rhai")),
|
||||
script_template(&candidate),
|
||||
)?;
|
||||
Ok(format!("{}/{candidate}.rhai", AssetKind::Script.folder()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -199,50 +124,4 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_covers_typed_names() {
|
||||
assert_eq!(sanitize_script_stem("spin"), "spin");
|
||||
assert_eq!(sanitize_script_stem(" My Cool Script! "), "My_Cool_Script");
|
||||
assert_eq!(sanitize_script_stem("door.rhai"), "door");
|
||||
assert_eq!(sanitize_script_stem("a//b\\c"), "a_b_c");
|
||||
assert_eq!(sanitize_script_stem("čárka"), "rka");
|
||||
// Unusable inputs fall back rather than producing "" or "_".
|
||||
assert_eq!(sanitize_script_stem(""), "new_script");
|
||||
assert_eq!(sanitize_script_stem("!!!"), "new_script");
|
||||
assert_eq!(sanitize_script_stem(".rhai"), "new_script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_script_writes_template_and_dodges_collisions() {
|
||||
let mut tmp = std::env::temp_dir();
|
||||
tmp.push(format!("oxide_newscript_{}", std::process::id()));
|
||||
let assets = tmp.join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
|
||||
let rel = create_script_file(&assets, "door opener").unwrap();
|
||||
assert_eq!(rel, "scripts/door_opener.rhai");
|
||||
let text = std::fs::read_to_string(assets.join(&rel)).unwrap();
|
||||
assert!(text.contains("fn update(dt)"));
|
||||
|
||||
// Same name again: suffixed, nothing overwritten.
|
||||
let rel2 = create_script_file(&assets, "door opener").unwrap();
|
||||
assert_eq!(rel2, "scripts/door_opener_2.rhai");
|
||||
let rel3 = create_script_file(&assets, "door opener").unwrap();
|
||||
assert_eq!(rel3, "scripts/door_opener_3.rhai");
|
||||
|
||||
std::fs::remove_dir_all(tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_compiles_in_the_script_engine() {
|
||||
// The template must never ship a syntax error: compile it exactly as
|
||||
// the runtime would.
|
||||
let asset = oxide_script::ScriptAsset::from_source(
|
||||
"new_script.rhai",
|
||||
script_template("new_script"),
|
||||
);
|
||||
let engine = oxide_script::ScriptEngine::new();
|
||||
engine.compile(&asset).expect("template compiles");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -2251,7 +2251,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
stroke_rect(
|
||||
&painter,
|
||||
tab_rect.shrink(1.5),
|
||||
egui::Stroke::new(3.0_f32, color),
|
||||
egui::Stroke::new(3.0, color),
|
||||
);
|
||||
painter.text(
|
||||
tab_rect.left_top() + egui::vec2(8.0, 6.0),
|
||||
@@ -2312,7 +2312,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
egui::Color32::from_rgb(110, 220, 120) // green: solid
|
||||
};
|
||||
let selected = self.state.selected == Some(entity);
|
||||
let stroke = egui::Stroke::new(if selected { 2.0_f32 } else { 1.2_f32 }, color);
|
||||
let stroke = egui::Stroke::new(if selected { 2.0 } else { 1.2 }, color);
|
||||
for (a, b) in collider_wire_segments(&col) {
|
||||
let (Some(pa), Some(pb)) = (
|
||||
project(pose.transform_point(a), &overlay.view_proj, tab_rect),
|
||||
@@ -2344,7 +2344,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
project(viz.origin, &overlay.view_proj, tab_rect),
|
||||
project(viz.end, &overlay.view_proj, tab_rect),
|
||||
) {
|
||||
painter.line_segment([a, b], egui::Stroke::new(1.5_f32, cyan));
|
||||
painter.line_segment([a, b], egui::Stroke::new(1.5, cyan));
|
||||
}
|
||||
|
||||
// The hit surface: a dot at the contact point + a normal whisker.
|
||||
@@ -2359,7 +2359,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
project(hit.point, &overlay.view_proj, tab_rect),
|
||||
project(tip, &overlay.view_proj, tab_rect),
|
||||
) {
|
||||
painter.line_segment([a, b], egui::Stroke::new(2.0_f32, magenta));
|
||||
painter.line_segment([a, b], egui::Stroke::new(2.0, magenta));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2470,7 +2470,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
painter.line_segment([pa, pb], egui::Stroke::new(2.5_f32, color));
|
||||
painter.line_segment([pa, pb], egui::Stroke::new(2.5, color));
|
||||
// Arrowhead at the tip — a small filled triangle perpendicular to
|
||||
// the line direction in screen space.
|
||||
let dir = (pb - pa).normalized();
|
||||
@@ -2521,7 +2521,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
} else {
|
||||
egui::Color32::from_rgba_unmultiplied(255, 120, 120, if engaged { 220 } else { 130 })
|
||||
};
|
||||
let stroke = egui::Stroke::new(1.5_f32, color);
|
||||
let stroke = egui::Stroke::new(1.5, color);
|
||||
painter.add(egui::Shape::convex_polygon(projected, color, stroke));
|
||||
}
|
||||
|
||||
@@ -2551,7 +2551,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
return;
|
||||
}
|
||||
let color = axis_color(axis, engaged);
|
||||
painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0_f32, color)));
|
||||
painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0, color)));
|
||||
}
|
||||
|
||||
fn hierarchy(&mut self, ui: &mut egui::Ui) {
|
||||
@@ -2705,7 +2705,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
DropZone::Child
|
||||
};
|
||||
let accent = ui.visuals().selection.bg_fill;
|
||||
let line = egui::Stroke::new(2.0_f32, accent);
|
||||
let line = egui::Stroke::new(2.0, accent);
|
||||
match zone {
|
||||
DropZone::Before => {
|
||||
ui.painter().line_segment(
|
||||
@@ -3333,7 +3333,7 @@ impl<'a> ShellTabViewer<'a> {
|
||||
if r.contains(pos) {
|
||||
let above = (pos.y - r.top()) < r.height() / 2.0;
|
||||
let accent = ui.visuals().selection.bg_fill;
|
||||
let line = egui::Stroke::new(2.0_f32, accent);
|
||||
let line = egui::Stroke::new(2.0, accent);
|
||||
let y = if above { r.top() } else { r.bottom() };
|
||||
ui.painter().line_segment(
|
||||
[egui::pos2(r.left(), y), egui::pos2(r.right(), y)],
|
||||
@@ -5165,7 +5165,7 @@ fn draw_ui_preview(ui: &mut egui::Ui, panel: &UiPanel) {
|
||||
stroke_rect(
|
||||
&painter,
|
||||
egui::Rect::from_min_size(origin, draw),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::DARK_GRAY),
|
||||
egui::Stroke::new(1.0, egui::Color32::DARK_GRAY),
|
||||
);
|
||||
|
||||
let theme = UiTheme::new();
|
||||
|
||||
@@ -178,30 +178,6 @@ pub fn asset_ref_target(type_name: &str) -> Option<&str> {
|
||||
Some(inner.rsplit("::").next().unwrap_or(inner).trim())
|
||||
}
|
||||
|
||||
/// Errors from [`AssetDatabase`] file operations ([`move_asset`], [`delete_asset`],
|
||||
/// [`move_folder`]) — the ops that touch both the on-disk file *and* the uid map.
|
||||
///
|
||||
/// [`move_asset`]: AssetDatabase::move_asset
|
||||
/// [`delete_asset`]: AssetDatabase::delete_asset
|
||||
/// [`move_folder`]: AssetDatabase::move_folder
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AssetDbError {
|
||||
/// The uid is not recorded in the database.
|
||||
#[error("unknown asset uid {0:?}")]
|
||||
UnknownUid(AssetUid),
|
||||
/// The destination path is already taken, in the database or on disk.
|
||||
/// Nothing was changed; pick another name.
|
||||
#[error("destination already exists: {0}")]
|
||||
DestinationExists(String),
|
||||
/// The path is empty, escapes the assets directory (`..`), or would move a
|
||||
/// folder into itself.
|
||||
#[error("invalid assets-relative path: {0:?}")]
|
||||
InvalidPath(String),
|
||||
/// The underlying filesystem operation failed.
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// A stable, per-project identifier for one asset.
|
||||
///
|
||||
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
|
||||
@@ -531,126 +507,6 @@ impl AssetDatabase {
|
||||
Some(server.load::<T>(path))
|
||||
}
|
||||
|
||||
// --- file operations (disk + uid map together) --------------------------
|
||||
//
|
||||
// These keep the uid attached to its asset across renames/moves — the whole
|
||||
// point of the uid layer — so the editor's file explorer can reorganise a
|
||||
// project without breaking any saved `AssetRef`. Deletion drops the entry
|
||||
// but never reuses its uid (the allocator only counts up).
|
||||
|
||||
/// Renames/moves the asset `uid` to `new_relative_path` (relative to
|
||||
/// `assets/`), on disk and in the map, **keeping its uid** so saved
|
||||
/// references still resolve. The kind is re-classified from the new path.
|
||||
/// Parent directories are created as needed; moving onto an existing path
|
||||
/// is refused. A no-op if the path is unchanged.
|
||||
///
|
||||
/// Call [`save`](Self::save) afterwards to persist the new path.
|
||||
pub fn move_asset(
|
||||
&mut self,
|
||||
uid: AssetUid,
|
||||
new_relative_path: impl AsRef<str>,
|
||||
) -> Result<(), AssetDbError> {
|
||||
let entry = self.by_uid.get(&uid).ok_or(AssetDbError::UnknownUid(uid))?;
|
||||
let new_path = checked_relative(new_relative_path.as_ref())?;
|
||||
if new_path == entry.path {
|
||||
return Ok(());
|
||||
}
|
||||
if self.by_path.contains_key(&new_path) {
|
||||
return Err(AssetDbError::DestinationExists(new_path));
|
||||
}
|
||||
let assets_dir = self.assets_dir();
|
||||
let from_abs = assets_dir.join(entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let to_abs = assets_dir.join(new_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
if to_abs.exists() {
|
||||
return Err(AssetDbError::DestinationExists(new_path));
|
||||
}
|
||||
if let Some(parent) = to_abs.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::rename(&from_abs, &to_abs)?;
|
||||
|
||||
let old_path = entry.path.clone();
|
||||
self.by_path.remove(&old_path);
|
||||
self.by_path.insert(new_path.clone(), uid);
|
||||
let entry = self.by_uid.get_mut(&uid).expect("entry checked above");
|
||||
entry.kind = AssetKind::classify(&new_path);
|
||||
entry.path = new_path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes the asset `uid`: removes its file from disk and drops its entry.
|
||||
/// A file already missing from disk is fine (the entry is still dropped);
|
||||
/// the uid is never reused. Call [`save`](Self::save) afterwards.
|
||||
pub fn delete_asset(&mut self, uid: AssetUid) -> Result<(), AssetDbError> {
|
||||
let entry = self.by_uid.get(&uid).ok_or(AssetDbError::UnknownUid(uid))?;
|
||||
let abs = self
|
||||
.assets_dir()
|
||||
.join(entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
match std::fs::remove_file(&abs) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let path = entry.path.clone();
|
||||
self.by_uid.remove(&uid);
|
||||
self.by_path.remove(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renames/moves the folder `from` to `to` (both relative to `assets/`),
|
||||
/// on disk and in the map: every entry under `from/` is rewritten to live
|
||||
/// under `to/`, **keeping its uid** (kinds are re-classified — moving out
|
||||
/// of a typed folder can change them). Refuses to overwrite an existing
|
||||
/// destination or to move a folder into itself. Returns how many entries
|
||||
/// moved. Call [`save`](Self::save) afterwards.
|
||||
pub fn move_folder(
|
||||
&mut self,
|
||||
from: impl AsRef<str>,
|
||||
to: impl AsRef<str>,
|
||||
) -> Result<usize, AssetDbError> {
|
||||
let from = checked_relative(from.as_ref())?;
|
||||
let to = checked_relative(to.as_ref())?;
|
||||
if from == to {
|
||||
return Ok(0);
|
||||
}
|
||||
if to.starts_with(&format!("{from}/")) {
|
||||
return Err(AssetDbError::InvalidPath(to));
|
||||
}
|
||||
let assets_dir = self.assets_dir();
|
||||
let from_abs = assets_dir.join(from.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
let to_abs = assets_dir.join(to.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
if !from_abs.is_dir() {
|
||||
return Err(AssetDbError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("no such folder under assets/: {from}"),
|
||||
)));
|
||||
}
|
||||
if to_abs.exists() {
|
||||
return Err(AssetDbError::DestinationExists(to));
|
||||
}
|
||||
if let Some(parent) = to_abs.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::rename(&from_abs, &to_abs)?;
|
||||
|
||||
let prefix = format!("{from}/");
|
||||
let moved: Vec<AssetUid> = self
|
||||
.by_uid
|
||||
.values()
|
||||
.filter(|e| e.path.starts_with(&prefix))
|
||||
.map(|e| e.uid)
|
||||
.collect();
|
||||
for &uid in &moved {
|
||||
let entry = self.by_uid.get_mut(&uid).expect("collected above");
|
||||
let new_path = format!("{to}/{}", &entry.path[prefix.len()..]);
|
||||
let old_path = std::mem::replace(&mut entry.path, new_path.clone());
|
||||
entry.kind = AssetKind::classify(&new_path);
|
||||
self.by_path.remove(&old_path);
|
||||
self.by_path.insert(new_path, uid);
|
||||
}
|
||||
Ok(moved.len())
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------
|
||||
|
||||
fn highest_uid(&self) -> u64 {
|
||||
@@ -666,20 +522,6 @@ fn normalize_relative(path: &str) -> String {
|
||||
trimmed.trim_start_matches('/').to_string()
|
||||
}
|
||||
|
||||
/// Normalizes like [`normalize_relative`] and rejects paths that are empty or
|
||||
/// escape the assets directory (`..` components) — file operations must never
|
||||
/// touch anything outside `assets/`.
|
||||
fn checked_relative(path: &str) -> Result<String, AssetDbError> {
|
||||
let norm = normalize_relative(path);
|
||||
let escapes = Path::new(&norm)
|
||||
.components()
|
||||
.any(|c| !matches!(c, std::path::Component::Normal(_)));
|
||||
if norm.is_empty() || escapes {
|
||||
return Err(AssetDbError::InvalidPath(path.to_string()));
|
||||
}
|
||||
Ok(norm)
|
||||
}
|
||||
|
||||
/// Recursively collects files under `dir`, pushing each one's path relative to
|
||||
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
|
||||
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
|
||||
@@ -896,128 +738,6 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_asset_keeps_uid_and_reclassifies() {
|
||||
let root = temp_root("move_asset");
|
||||
touch_asset(&root, "textures/wall.png");
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
db.scan();
|
||||
let uid = db.uid_of("textures/wall.png").unwrap();
|
||||
|
||||
// Rename within the folder: same uid, file moved on disk.
|
||||
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||
assert_eq!(db.relative_path(uid), Some("textures/brick.png"));
|
||||
assert!(db.uid_of("textures/wall.png").is_none());
|
||||
assert!(!root.join(ASSETS_DIR).join("textures/wall.png").exists());
|
||||
assert!(root.join(ASSETS_DIR).join("textures/brick.png").exists());
|
||||
|
||||
// Move into a subfolder that does not exist yet (created), then out of
|
||||
// the typed folder entirely — kind follows the path.
|
||||
db.move_asset(uid, "textures/env/brick.png").unwrap();
|
||||
assert!(root
|
||||
.join(ASSETS_DIR)
|
||||
.join("textures/env/brick.png")
|
||||
.exists());
|
||||
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Texture);
|
||||
db.move_asset(uid, "misc/brick.dat").unwrap();
|
||||
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
||||
|
||||
// A rescan does not disturb the moved entry's uid... (misc/ is not a
|
||||
// typed folder, so the entry survives only because scan never saw it —
|
||||
// move back first to prove the typed-folder case.)
|
||||
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||
db.scan();
|
||||
assert_eq!(db.uid_of("textures/brick.png"), Some(uid));
|
||||
|
||||
// No-op and error cases.
|
||||
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
||||
touch_asset(&root, "textures/taken.png");
|
||||
db.scan();
|
||||
assert!(matches!(
|
||||
db.move_asset(uid, "textures/taken.png"),
|
||||
Err(AssetDbError::DestinationExists(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
db.move_asset(uid, "../outside.png"),
|
||||
Err(AssetDbError::InvalidPath(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
db.move_asset(AssetUid(999), "textures/x.png"),
|
||||
Err(AssetDbError::UnknownUid(_))
|
||||
));
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_asset_removes_file_and_entry_without_reusing_uid() {
|
||||
let root = temp_root("delete_asset");
|
||||
touch_asset(&root, "audio/click.wav");
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
db.scan();
|
||||
let uid = db.uid_of("audio/click.wav").unwrap();
|
||||
|
||||
db.delete_asset(uid).unwrap();
|
||||
assert!(db.entry(uid).is_none());
|
||||
assert!(!root.join(ASSETS_DIR).join("audio/click.wav").exists());
|
||||
|
||||
// The freed uid is not handed to the next registration.
|
||||
let next = db.register("audio/other.wav");
|
||||
assert_ne!(next, uid);
|
||||
|
||||
// Deleting an entry whose file is already gone still drops the entry.
|
||||
std::fs::remove_file(root.join(ASSETS_DIR).join("audio/other.wav")).ok();
|
||||
db.delete_asset(next).unwrap();
|
||||
assert!(db.entry(next).is_none());
|
||||
|
||||
assert!(matches!(
|
||||
db.delete_asset(uid),
|
||||
Err(AssetDbError::UnknownUid(_))
|
||||
));
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_folder_rewrites_entries_and_guards_self_moves() {
|
||||
let root = temp_root("move_folder");
|
||||
touch_asset(&root, "textures/env/wall.png");
|
||||
touch_asset(&root, "textures/env/floor.png");
|
||||
touch_asset(&root, "textures/hero.png");
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
db.scan();
|
||||
let wall = db.uid_of("textures/env/wall.png").unwrap();
|
||||
let hero = db.uid_of("textures/hero.png").unwrap();
|
||||
|
||||
let moved = db.move_folder("textures/env", "textures/world").unwrap();
|
||||
assert_eq!(moved, 2);
|
||||
assert_eq!(db.relative_path(wall), Some("textures/world/wall.png"));
|
||||
assert!(db.uid_of("textures/world/floor.png").is_some());
|
||||
assert_eq!(db.relative_path(hero), Some("textures/hero.png"));
|
||||
assert!(root
|
||||
.join(ASSETS_DIR)
|
||||
.join("textures/world/wall.png")
|
||||
.exists());
|
||||
assert!(!root.join(ASSETS_DIR).join("textures/env").exists());
|
||||
|
||||
// Guards: into itself, onto an existing folder, missing source.
|
||||
assert!(matches!(
|
||||
db.move_folder("textures", "textures/world/deeper"),
|
||||
Err(AssetDbError::InvalidPath(_))
|
||||
));
|
||||
std::fs::create_dir_all(root.join(ASSETS_DIR).join("models")).unwrap();
|
||||
assert!(matches!(
|
||||
db.move_folder("textures/world", "models"),
|
||||
Err(AssetDbError::DestinationExists(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
db.move_folder("textures/nope", "textures/x"),
|
||||
Err(AssetDbError::Io(_))
|
||||
));
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
struct TxtLoader;
|
||||
impl crate::asset::AssetLoader for TxtLoader {
|
||||
type Asset = String;
|
||||
|
||||
@@ -18,8 +18,7 @@ mod handle;
|
||||
mod server;
|
||||
|
||||
pub use database::{
|
||||
asset_ref_target, AssetDatabase, AssetDbError, AssetEntry, AssetKind, AssetRef, AssetUid,
|
||||
ASSET_MANIFEST_FILE,
|
||||
asset_ref_target, AssetDatabase, AssetEntry, AssetKind, AssetRef, AssetUid, ASSET_MANIFEST_FILE,
|
||||
};
|
||||
pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel};
|
||||
pub use handle::{AssetId, Handle, LoadState};
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
//! editor canvas previews).
|
||||
//!
|
||||
//! A future depth-aware mode (where a panel behind a wall is properly
|
||||
//! hidden) is in the wiki Roadmap's Stage-8 backlog and
|
||||
//! hidden) is in [`PLAN.md`](../../../../PLAN.md)'s Stage-8 backlog and
|
||||
//! slots in by attaching a depth attachment to a second pass of the
|
||||
//! same pipeline.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! focus per widget, and tell the host whether the UI captured the frame's
|
||||
//! input so the game can decide whether to also handle it.
|
||||
//!
|
||||
//! Stage 8's UI must *consume input before the game* (see the wiki Roadmap): if the
|
||||
//! Stage 8's UI must *consume input before the game* (PLAN.md): if the
|
||||
//! cursor is over a button, clicking shouldn't also fire the game-world
|
||||
//! action bound to that mouse button. The [`Router`] gives the host one
|
||||
//! object to drive each frame:
|
||||
|
||||
Reference in New Issue
Block a user