The engine documentation, moved out of the repository

Twenty-six docs/ pages, the staged plan, the session handoff and the project
rules, which were CLAUDE.md. Every internal link is rewritten: page-to-page
links became wiki references, and links into the engine's own source became
absolute URLs back into the repository, because a relative path only meant
something while the document lived inside the tree.

Three things were changed rather than copied. The instructions telling an agent
to keep CLAUDE.md, PLAN.md and docs/ up to date now name this wiki -- left as
they were, they would have recreated the files this move removes. The stale
'Local: /home/homer/Oxide' line is gone. And a rustdoc intra-doc link in
Modules, which was never valid markdown and rendered as a broken link inside
the repository too, is now plain code text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:11:58 +02:00
parent abf106a128
commit 3a072ada43
29 changed files with 6820 additions and 1 deletions
+157
@@ -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)).
## 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`](Roadmap) 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) and [`PLAN.md`](Roadmap).
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) — coordinate system, units, color space
- [development.md](Development) — workflow, testing, and how stages progress
- [`PLAN.md`](Roadmap) — the full staged roadmap
+246
@@ -0,0 +1,246 @@
# 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) |
| `Script` | `scripts/` | `rhai` |
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
file extension as a fallback for files dropped directly in `assets/`.
### File operations — rename, move, delete
The database also *performs* file reorganisation, so the editor's file explorer
(and any tool) can rename or move assets **without breaking saved references**
— the disk operation and the uid map are updated together:
```rust
# use oxide_engine::asset::{AssetDatabase, AssetUid};
# fn demo(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), oxide_engine::asset::AssetDbError> {
db.move_asset(uid, "textures/env/brick.png")?; // rename/move; uid unchanged
db.move_folder("textures/env", "textures/world")?; // every entry under it follows
db.delete_asset(uid)?; // file + entry; uid never reused
db.save()?; // persist the new paths
# Ok(())
# }
```
All three refuse to touch anything outside `assets/` (`..` is rejected) and
refuse to overwrite an existing destination ([`AssetDbError`] has a variant per
failure). Kinds are re-classified from the new path, since a move can change
the typed folder. Deleting drops the entry but never reuses its uid — a
dangling reference stays dangling instead of silently pointing at a new file.
### 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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
[`AssetDbError`]: ../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
+90
@@ -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#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#aabb) is *empty* when any `min` component exceeds the
corresponding `max`; `Aabb::EMPTY` is the identity for `union`.
- A [`Ray`](Math#ray) always stores a **normalized** direction, so its
parameter `t` is a true distance.
- A [`Plane`](Math#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#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.
+111
@@ -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`](Roadmap)):
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/`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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 on this wiki — 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: [Working-notes](Working-notes), [Roadmap](Roadmap), the
repository's `README.md` and `.gitignore`, and the relevant wiki pages.
## Adding a new stage
1. Read [`PLAN.md`](Roadmap) 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 as a wiki page and link it from
[`docs/README.md`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/README.md).
5. Ensure the full testing protocol passes.
6. Update [Roadmap](Roadmap) (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.
+142
@@ -0,0 +1,142 @@
# Editor Extension API
`oxide_editor::extension` is the editor-side companion to the engine's
[`Module`](Modules) 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). Settings pages plug into the
[Preferences framework](Settings) 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
+181
@@ -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).
Lives in [`oxide_editor::shell`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/editor/src/shell.rs) (the library) with
[`oxide-editor`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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) + `RecentProjects` + `Shell::take_quit_request` |
| Edit menu → Undo / Redo (Ctrl+Z / Ctrl+Y) | [`CommandStack`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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) |
| 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) + `EditorExtensions` |
| File watcher (on project open) → `AssetServer::reload_path` | [`FileWatcher`](File-watching) |
## 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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) over the project's `assets/`, `scenes/`, and
`scripts/` directories (~150 ms debounce window). The shell's
[`frame_tick`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/editor/src/shell.rs) pumps events through
[`reload_changed_assets`](File-watching) 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) 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)) 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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
+154
@@ -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)) 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). 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
+113
@@ -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
```
+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 [Roadmap](Roadmap); project rules are [Working-notes](Working-notes); 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 58.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 18c 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 [Scripting](Scripting).
### 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 [Physics](Physics).
### 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 [Roadmap](Roadmap), the repository's `README.md`
and this wiki current alongside the code. Physics is on [Physics](Physics).
### 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 → the [Roadmap](Roadmap) 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: [Scripting](Scripting). 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
[Scripting](Scripting) ("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).
+63 -1
@@ -1 +1,63 @@
Placeholder. Replaced by the first push.
# Oxide Engine
A general-purpose 3D game engine in Rust, with an in-engine editor
(`oxide-editor`) developed alongside it. It scales from stylized low-poly to
realistic graphics and **ships only what each game uses** — every subsystem is a
feature-gated module, and an exported game compiles in only the modules it
registers.
The guiding idea is **build tools, not games**.
Source: [`Houmeres/Oxide`](https://git.houmeres.sk/Houmeres/Oxide) ·
build and install instructions are in the
[README](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/README.md).
## Start here
| Page | What it is |
|---|---|
| [Working-notes](Working-notes) | The project's rules — goals, philosophy, the branch workflow, the tooling choices. Read before changing anything. |
| [Roadmap](Roadmap) | The authoritative staged plan, Stages 016 and the Phase-2 modules. |
| [Handoff](Handoff) | Where the work actually is right now, and what to do next. |
| [Getting-started](Getting-started) | Toolchain, building, running examples, tests and benchmarks. |
| [Architecture](Architecture) | Workspace layout, crate responsibilities, the staged model. |
| [Conventions](Conventions) | Coordinate system, handedness, units, colour space. |
| [Development](Development) | Branch workflow (`dev`/`main`), testing protocol, how to add a stage. |
## By subsystem
| Page | Covers | Stage |
|---|---|---|
| [Math](Math) | The `oxide_engine::math` module | 1 |
| [Windowing](Windowing) | Window creation, the `App` trait, the event loop, raw input | 2 |
| [Render-context](Render-context) | GPU acquisition, surface configuration, the frame loop | 2 |
| [Scene](Scene) | Scene graph, entities, transform hierarchy, serialization | 3 |
| [Rendering](Rendering) | Meshes, materials, camera, the forward renderer | 4 |
| [Modules](Modules) | App, modules and scheduling; system phases, fixed timestep | 5 |
| [Layers](Layers) | `Layer`, `Tags`, `GroupRegistry` and the shared `LayerMask` filter | 5 |
| [Reflection](Reflection) | Type registry; name-keyed component access for dual-editability | 5 |
| [Assets](Assets) | Asset server and handles: ref-counted loading, dedup, reload | 5 |
| [Render-pipeline](Render-pipeline) | Data-driven render passes, scalable fidelity, camera visibility | 5 |
| [Projects](Projects) | Project file, folder layout, create/open/save, recents | 6 |
| [Settings](Settings) | Typed settings sections, export/import, per-module and per-project | 6 |
| [File-watching](File-watching) | Debounced change events; asset live-reload wiring | 6 |
| [Editor-extensions](Editor-extensions) | Module → editor API: menus, panels, tools, inspectors | 6 |
| [Editor-shell](Editor-shell) | Docking shell, menu bar, status bar, Preferences, command stack | 6 |
| [Input](Input) | `InputState`, named actions, remapping, RON persistence, axes | 7 |
| [UI](UI) | Widget tree, layout, theming, text shaping, hit-test and routing | 8 |
| [Prefabs](Prefabs) | Data-driven named spawn templates | 8.5 |
| [Play-mode](Play-mode) | `PlayState`, scene snapshot/restore, the scene-swap runner | 8.7 |
| [Physics](Physics) | `oxide-physics` on rapier3d: bodies, colliders, filtered collision | 9 |
| [Scripting](Scripting) | `oxide-script` on rhai: the `Script` component, live reload | 10 |
## How this documentation works
Documentation is written **alongside** the code, not after it: a stage is not
done until its page exists here. One topic per page; link between pages rather
than duplicating. When an API changes, the page and its code snippets change in
the same commit, so the documentation cannot drift from the engine.
These pages used to be `docs/`, `CLAUDE.md`, `PLAN.md` and `HANDOFF.md` inside
the repository. They moved here on 2026-08-08 and were removed from the
repository's history in the same pass — a repository holds the software and what
ships with it; what is written *about* the work lives here.
+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) vocabulary the runner pumps from,
see [windowing.md](Windowing).
## 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) loop owns one `InputState` and:
1. Pumps every incoming [`WindowEvent`](Windowing) 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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).
## 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#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 GribbHartmann 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),
- the shared [`AssetServer`](Assets),
- the [`TypeRegistry`](Reflection) (dual-editable components),
- the project's [`LayerRegistry`](Layers),
- 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) 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` 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) 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) 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) 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)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), 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
+120
@@ -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) 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) 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(&registry); // capture
// … play-mode mutations …
let scene = snap.restore(&registry)?; // revert (fresh entity handles)
```
Fidelity is bounded by what the [`TypeRegistry`](Reflection) 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). 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)).
## 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.
+83
@@ -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) and [reflection.md](Reflection)). 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):
> "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)); 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 on [Roadmap](Roadmap).
[`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
+85
@@ -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), 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
+191
@@ -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
+105
@@ -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).
## 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); 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.
+115
@@ -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) carries a `visibility` [`LayerMask`](Layers): 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
+186
@@ -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](Roadmap)).
All of these types live in `oxide_engine::render` and are re-exported from the
[prelude](Getting-started).
## 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) 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.11000 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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) (surface/clear loop),
[conventions.md](Conventions) (handedness, color space), [scene.md](Scene)
(transforms and the hierarchy that feeds object placement).
+1230
File diff suppressed because it is too large Load Diff
+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) 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); for coordinate and
units conventions, see [conventions.md](Conventions).
## 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).
- 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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.
+332
@@ -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): 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), 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), 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
+70
@@ -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) 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) 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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/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`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/engine/src/ui/visual.rs),
resolved through the [asset database](Assets).
## 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/`](https://git.houmeres.sk/Houmeres/Oxide/src/branch/main/engine/src/ui) and is re-exported through the engine
prelude under disambiguated names (`UiSizing`, `UiAnchor`, `Widget`, …) so it
doesn't collide with the Stage-1 math types.
## Building a widget tree
The `Widget::row()`, `Widget::column()`, `Widget::grid(cols, rows)`,
`Widget::anchor()`, and `Widget::leaf(intrinsic)` constructors plus the
`with_*` builder methods produce trees declaratively. Builder methods that
only make sense on certain kinds (`with_gap` on a stack, `with_grid_gap` on a
grid, `with_child` on any container) panic with a clear message when called
on the wrong kind — catching author mistakes during construction instead of
producing a silently misshapen UI at layout time.
```rust
use oxide_engine::math::Vec2;
use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
let toolbar = Widget::row()
.with_id("toolbar")
.with_gap(8.0)
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Fixed(32.0),
padding: Insets::all(4.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("edit"));
```
## Sizing
`Sizing` controls how a widget asks to be sized along one axis.
| Variant | Behavior |
|---------|----------|
| `Fixed(f32)` | Fixed logical size; multiplied by the layout scale factor. |
| `Grow(f32)` | Take a share of the parent's leftover space, weighted by `f32`. Two siblings with `Grow(1.0)` split evenly; `Grow(2.0)` next to `Grow(1.0)` takes 2/3. A non-positive weight contributes nothing. |
| `FitContent` (default) | Fit the widget's intrinsic content size — leaves use their `intrinsic`, containers use the recursive content extent. |
The defaults of `FitContent × FitContent` are intentional: leaves are sized
by what they contain, containers are sized by what they wrap. A root widget
that wants to **fill the viewport** must opt in with
`Sizing::Grow(_)` on both axes (or set `Fixed` extents) — the layout function
makes no special root case.
## Padding, margin, alignment
- **`padding`** shrinks a widget's `content_rect`, the area inside which
children are arranged. Multiplied by the scale factor.
- **`margin`** reserves space *outside* the widget's rect, so siblings don't
touch it. In a stack, margin is added to the child's main-axis footprint
before grow accounting.
- **`align_horizontal` / `align_vertical`** position a widget within its
parent's slot when the widget's resolved size is **smaller** than the slot.
In a stack, cross-axis alignment lets a short child dock to the top,
middle, or bottom of its row. (The stack-level `main_align` does the
analogous thing on the main axis when there's no `Grow` child to absorb
leftover space.)
## Layout modes
### Stack (`StackDirection::Row` / `Column`)
1. Allocate each child's **main-axis** size:
- `Fixed(v)``v * scale`,
- `FitContent` → recursive intrinsic measurement,
- `Grow(w)` → reserved (zero first), then assigned a share of leftover
space proportional to `w`.
2. **Cross-axis** sizing happens during the child's own `arrange_in_slot`
pass: `Grow` fills the parent's cross extent; the other variants leave
space the child's `align_*` consumes.
3. With no `Grow` child, the stack's `main_align` (Start / Center / End)
positions the children's combined footprint inside the content rect.
### Grid
Equal-cell `cols × rows` layout. Cell size is computed from the parent's
content rect after subtracting `(cols - 1) * gap.x` and `(rows - 1) * gap.y`.
Children fill cells left-to-right, top-to-bottom; extras past `cols * rows`
are ignored. Within a cell the child's own `align_*` and sizing decide how it
positions itself — `Grow` fills the cell, `Fixed`/`FitContent` aligns inside
it.
More flexible grids (auto-sized rows/columns, spans) are a follow-up; the
equal-cell case covers the Stage-7 bindings preferences page and the Stage-8
settings examples.
### Anchor
Each child specifies its own `Anchor` in `LayoutStyle::anchor`. The anchor is
two normalized points in `[0, 1]²` (the anchor rectangle) plus per-corner
offsets in logical pixels:
```text
rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
```
The Unity/Godot convention applies: the anchor is **authoritative**. An
anchor child's `width`, `height`, `margin`, and `align_*` are ignored along
the axes the anchor constrains; padding still applies (it's an
inside-the-rect concern). The
`Anchor::FILL`, `Anchor::TOP`, `Anchor::TOP_LEFT`, `Anchor::BOTTOM_RIGHT`, …
constants cover the common cases, and `Anchor::between(min, max)` +
`with_offsets(min, max)` is the escape hatch.
## DPI
All linear inputs (sizing, padding, margin, gap, anchor offsets) are in
**logical pixels** and multiplied by the `scale` factor passed to
[`layout`]. The widget tree is DPI-independent; the layout call is where the
display's scale factor enters. The same widget tree laid out at `scale=1.0`
inside a 800 × 600 viewport and at `scale=2.0` inside a 1600 × 1200 viewport
produces identically *proportioned* rects, with every dimension doubled —
verified by an integration test.
## Widget ids and lookups
`WidgetId(pub String)` is the author-facing identifier. UI documents ship
their string ids straight through RON (`"play"`, `"volume-slider"`), so a
visual editor, a hand-edited file, and game code all refer to the same
widget. The empty id (`""`) is the default and means "anonymous"; multiple
anonymous widgets are allowed and `LayoutTree::find` rejects lookups by empty
id.
`LayoutTree::find(id)` is a linear scan — fine for the dozens-of-widgets
trees Stage 8 currently targets; a hash-map index can be added if a profile
ever says it's hot.
## RON dual-edit
Every type in the module derives `Serialize + Deserialize` and round-trips
through RON. `Widget::to_ron()` produces the pretty-printed canonical form
the editor's UI canvas saves and the runtime loads; `Widget::from_ron(text)`
parses it. The Stage-8 integration suite verifies that the round-trip
**preserves layout** — the laid-out trees match — so an external editor or AI
agent can edit the same file the runtime loads.
## What's in piece 2 — styling & theming
Visual styling is intentionally **orthogonal** to layout — layout decides
where a widget is; visual styling decides what it looks like. Adding a
`VisualStyle` or `theme_style` to a widget never changes its laid-out rect.
The integration suite verifies this with a paired `layout()` call before and
after styling.
The data:
- **`VisualStyle`** — a flat struct of `Option<T>` fields: `background`,
`foreground`, `border` (color + width), `corner_radius`, `font`, and
`font_size`. `None` means *inherit*; `Some` means *override*. Every field
serializes via `skip_serializing_if = "Option::is_none"`, so an empty
visual style vanishes from RON entirely.
- **`Theme`** — `default: VisualStyle` plus `styles: BTreeMap<String,
VisualStyle>`. The `BTreeMap` (not `HashMap`) gives deterministic RON
output, important for diff-friendly UI documents and reproducible test
snapshots.
- **`Widget`** gains two fields: `visual: VisualStyle` (per-instance
overrides) and `theme_style: Option<String>` (opt-in name into the
theme's named map).
The cascade — implemented by `Theme::resolve(style_ref, override_with)` and
exposed on the widget as `Widget::resolve_visual(&theme)`:
1. Start with `theme.default`.
2. If the widget specifies `theme_style: Some(name)` and the theme has a
matching entry, merge it on top (a missing name is treated as "no
contribution", not an error).
3. Merge the widget's per-instance `visual` on top.
Each merge is field-by-field via `VisualStyle::merged(self, override_with)`:
right-hand `Some` wins, otherwise the left-hand value is kept. The same
primitive will drive runtime state overlays in piece 5 (hover, focus,
press).
`FontRef` carries `family`, `weight: FontWeight`, and `italic: bool`. The
descriptor stores **names**, not paths: portable across machines, and the
runtime (piece 3) is free to pick the platform's best match. `FontWeight`
exposes `opentype_value()` returning the OpenType 100900 weight scale.
`VisualStyle` also has a `font_asset: Option<AssetRef<Font>>` (Stage 8.5 piece
7): a reference to a **specific project font asset** under `assets/fonts/`,
chosen in the editor's UI canvas from the asset browser. When set it takes
precedence over the `font` descriptor — the renderer resolves the [`AssetRef`]
to a loaded face through the [asset database](Assets) (a default-registered
`FontLoader` makes `.ttf`/`.otf` loadable via the `AssetServer`). `None` falls
back to the descriptor / theme path. This is the engine's first `AssetRef<T>`
field and the asset-picker's end-to-end target.
[`AssetRef`]: ../engine/src/asset/database.rs
### Quick example
```rust
use oxide_engine::math::{Color, Vec2};
use oxide_engine::ui::{Border, FontRef, Theme, VisualStyle, Widget};
let theme = Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
font: Some(FontRef::regular("Inter")),
font_size: Some(14.0),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
corner_radius: Some(4.0),
..VisualStyle::EMPTY
},
);
let play = Widget::leaf(Vec2::new(80.0, 24.0))
.with_id("play")
.with_theme_style("button")
.with_visual(VisualStyle {
background: Some(Color::rgb(0.2, 0.4, 0.8)), // primary-button accent
foreground: Some(Color::WHITE),
..VisualStyle::EMPTY
});
let resolved = play.resolve_visual(&theme);
assert_eq!(resolved.foreground, Some(Color::WHITE)); // per-instance wins
assert_eq!(resolved.corner_radius, Some(4.0)); // inherited from "button"
assert_eq!(resolved.font, Some(FontRef::regular("Inter"))); // inherited from default
```
### RON dual-edit
`Theme::to_ron` / `Theme::from_ron` round-trip themes through pretty-printed
RON, matching `Widget::to_ron` from piece 1. `BTreeMap`-ordered output keeps
named styles alphabetised so diffs are stable. Empty fields (`None` options,
empty maps, `FontWeight::Regular`, `italic: false`) skip serializing — the
default form of any of these structs is `()` in RON.
## What's in piece 3 — text shaping & glyph atlas
The text subsystem lives at `oxide_engine::ui::text` and splits into three
sub-modules that compose, but each is testable on its own:
- **`font`** — owns `Font` (a thin wrapper around `ab_glyph::FontVec`),
`FontId`, and `FontStore`. `Font::rasterize(glyph, size_px)` returns a
`RasterizedGlyph` with an alpha mask + per-glyph bearings + advance.
`FontStore::insert_with_descriptor(FontRef, Font)` indexes a font under a
piece-2 `FontRef`, so a theme's `font: Some(FontRef::bold("Inter"))`
resolves to a `FontId` the shaper can use.
- **`atlas`** — `GlyphAtlas::new(width, height)` allocates a single R8
(alpha-only) buffer; `get_or_rasterize(GlyphKey, &FontStore)` returns the
glyph's `AtlasEntry` (UV rect + size + bearing + advance), rasterizing
and packing on first miss and serving the cache forever after. The
packer is a **best-fit shelf packer** — simple, deterministic, and
near-optimal density for the typically-uniform glyph heights of one font
at one size. The `dirty()` flag tells the piece-4 render pass when the
texture needs re-upload.
- **`shape`** — `shape(text, style, &params, &fonts)` turns a string into
a `ShapedText { lines, size }` of positioned `ShapedGlyph`s. Each glyph
carries a `GlyphKey` the renderer feeds back into the atlas, and a
`position` at the **baseline** (not the top-left). Algorithm:
greedy line-break at ASCII whitespace, multi-font runs supported via
`shape_runs(&[TextRun])`, alignment within `max_width` (Left / Center /
Right), DPI scaling via `ShapeParams::scale`.
### The atlas is the cache
`GlyphAtlas` keys entries by `(FontId, GlyphId, size_px rounded to nearest
integer)`. Every glyph is rasterized **exactly once** per (font, glyph,
size) triple — a HUD that repaints `"HP: 1234 / 1500"` every frame
rasterizes the ten ASCII characters one time at startup and then runs
purely on textured quads. The integration suite verifies this:
`shaped_hud_text_is_cached_after_one_frame` shapes a three-line HUD,
walks every glyph through the atlas twice, and asserts the atlas's
`dirty` flag stays false on the second pass — i.e., zero new
rasterizations. The library choice (ab_glyph vs fontdue) only affects
the one-time miss cost, not steady-state.
### Quick example
```no_run
use oxide_engine::math::Vec2;
use oxide_engine::ui::text::{
shape, Font, FontStore, GlyphAtlas, ShapeParams, TextAlign, TextStyle,
};
let mut fonts = FontStore::new();
let id = fonts.insert(Font::from_path("/usr/share/fonts/.../Inter-Regular.ttf").unwrap());
let style = TextStyle { font: id, size_px: 14.0 };
let params = ShapeParams {
max_width: Some(300.0),
align: TextAlign::Center,
line_height: 1.4,
scale: 1.0,
};
let shaped = shape("Press F to pay respects", style, &params, &fonts);
let mut atlas = GlyphAtlas::new(1024, 1024);
for line in &shaped.lines {
for glyph in &line.glyphs {
// Render with the atlas's bearing offset; this is exactly the
// call piece 4's overlay pass will make per glyph per frame.
if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
let quad_top_left: Vec2 = glyph.position + entry.bearing;
let _quad_size: Vec2 = entry.size_px;
let _ = (quad_top_left, entry.uv_min, entry.uv_max);
}
}
}
```
### Limitations (deliberate, scoped to piece 3)
- One glyph per `char` — no ligatures, no combining marks, no complex-
script shaping (Arabic, Devanagari, Thai). The data path is ready for
a future `rustybuzz`-shaped intermediate; the current shaper just
doesn't invoke one.
- No BiDi or RTL — text flows left-to-right.
- No hyphenation or character-level break inside an over-wide word.
- ASCII whitespace only (`\t` and `\r` are treated as spaces).
- No bold/italic synthesis — each face is a separately-loaded `Font`.
### Font choice
The engine doesn't bundle a font; piece-3 tests use whichever sans-serif
they find on `/usr/share/fonts/` (or `/System/Library/Fonts` on macOS) via
`common_system_font_paths()`, skipping with `eprintln!("SKIP: …")` when no
candidate is present. The default UI font shipped with examples is a
piece-7 decision.
### Why ab_glyph
`ab_glyph` is a TTF parser + rasterizer only. It does not do layout —
which is fine because the shaper above already owns that. With
`fontdue` we would have gotten line wrapping for free at the cost of
living inside a fixed layout model; with `ab_glyph` we own every line-
break, kerning, and alignment decision. That control buys us a clean
path to richer features later: rich-text markup, per-character
animation, in-canvas editor caret positioning, and **SDF font
rendering** — a future follow-up where each glyph is rasterized once
as a signed-distance field and the shader scales it to any size for
free. SDF is on the Stage-8 backlog in [PLAN.md](Roadmap); 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) 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.
+144
@@ -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).
## 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),
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) 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) 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) |
| `input()` | The per-frame [`InputState`](Input) 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)) |
| `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`.
+183
@@ -0,0 +1,183 @@
# Oxide Engine — working notes
The project's rules and context. This page was the repository's `CLAUDE.md`
until 2026-08-08; it is loaded by reading it, not automatically, so read it
before starting work.
## 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 [Roadmap](Roadmap)):
- **Phase 1 — general-purpose engine (Stages 016):** 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 [Roadmap](Roadmap) 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
**The documentation is this wiki, and it is not in the repository.** It moved
here on 2026-08-08 and was removed from the repository's history in the same
pass. The repository holds the engine and what ships with it — `README.md`,
`LICENSE`, `install.sh` — and nothing written *about* the work. Do not recreate
`CLAUDE.md`, `PLAN.md`, `HANDOFF.md` or a `docs/` directory inside it.
- **Always update this page, [Roadmap](Roadmap), the repository's `README.md`,
and `.gitignore`** when project rules, goals, stage definitions, or project
structure change.
- [Roadmap](Roadmap) is the authoritative roadmap — keep it accurate and
up-to-date.
- `README.md` stays in the repository and must reflect the current build/install
instructions and feature list at all times. Keep it a **short overview**
detailed documentation belongs on this wiki, not in 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 any of this become stale after structural or process changes.
### The engine documentation
- This wiki holds the full documentation of the engine — both **usage** (how to
call each system) and **inner workings** (how/why it works). The project is far
too large to fit that in `README.md`.
- **Write documentation as you work, not after.** A stage is not complete until
its systems are documented here.
- One topic per page; link between pages rather than duplicating. [Home](Home)
is the index — add new pages to it.
- When an API changes, update the affected page and its code snippets **in the
same session** so the documentation never drifts from the code. It is a
separate repository (`Oxide.wiki`, cloned beside `Oxide`), so this is a second
commit rather than part of the code commit — push both.
## 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
[Roadmap](Roadmap) 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: `ssh://git@git.houmeres.sk:2222/Houmeres/Oxide.git`
- Documentation: this wiki, cloned beside the repository as `Oxide.wiki/`
## Git Workflow & Branching
Two long-lived branches: **`dev`** (integration) and **`main`** (stable). Full
detail in [Development](Development); the rules an agent 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 [Roadmap](Roadmap) for the full staged plan 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 this page, [Roadmap](Roadmap), and the repository's `README.md` before considering the task done