diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..7c7d4ec --- /dev/null +++ b/Architecture.md @@ -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 diff --git a/Assets.md b/Assets.md new file mode 100644 index 0000000..c32dfc5 --- /dev/null +++ b/Assets.md @@ -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`]: 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 = assets.load("assets/models/cube.gltf"); + +if let Some(model) = model.get() { // Option>, None until loaded + println!("{} meshes", model.meshes.len()); +} +``` + +Key handle methods: + +| Method | Meaning | +|--------|---------| +| `get()` | `Some(Arc)` 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 = assets.load("model.gltf"); +let b: Handle = 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 = 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 { + 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 = assets.load("model.gltf"); +// ... the file changes on disk ... +let same = assets.reload::("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::(&server, uid); // Option> +``` + +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`] field — **not** a live +`Handle`. A handle is process-local and not serializable, so persisting one +would be wrong; an `AssetRef` is a thin, serializable wrapper over +`Option` 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 } // serializes as just the uid + +# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) { +let handle = label.font.resolve(db, server); // Option> +# } +``` + +Because `AssetRef` 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` 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`]: ../engine/src/asset/database.rs +[`asset_ref_target`]: ../engine/src/asset/database.rs +[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs +[`Handle`]: ../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 diff --git a/Conventions.md b/Conventions.md new file mode 100644 index 0000000..28d4330 --- /dev/null +++ b/Conventions.md @@ -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. diff --git a/Development.md b/Development.md new file mode 100644 index 0000000..32abbbe --- /dev/null +++ b/Development.md @@ -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. diff --git a/Editor-extensions.md b/Editor-extensions.md new file mode 100644 index 0000000..a83d99a --- /dev/null +++ b/Editor-extensions.md @@ -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>`). + +## 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 diff --git a/Editor-shell.md b/Editor-shell.md new file mode 100644 index 0000000..1a9596b --- /dev/null +++ b/Editor-shell.md @@ -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 diff --git a/File-watching.md b/File-watching.md new file mode 100644 index 0000000..d9daa83 --- /dev/null +++ b/File-watching.md @@ -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 diff --git a/Getting-started.md b/Getting-started.md new file mode 100644 index 0000000..e503a5d --- /dev/null +++ b/Getting-started.md @@ -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 +``` diff --git a/Handoff.md b/Handoff.md new file mode 100644 index 0000000..9ed46a0 --- /dev/null +++ b/Handoff.md @@ -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 5–8.7: ✅ on `main`.** Engine core, editor framework, + input + gizmos, comprehensive UI, Reflection v2 + asset database (Stage 8.5), + and editor Play Mode (Stage 8.7). +- **Phase 1 / Stage 9 — Physics: ✅ complete on `main`.** All pieces 1–8c done + and promoted (eye-checked & approved 2026-06-17). +- **Phase 1 / Stage 10 — Scripting: 🚧 core done on `main`** (crate, lifecycle, + live reload, editor integration, Console, command terminal, interactive PTY + terminal — all eye-checked & approved; **richer script API** + spawn/despawn/component-edit — pure-logic, on `main`). One follow-up remains + (the editor-UX batch — GUI, needs eye-check). Piece table + what's next in §5; + details in [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` not `Handle` in components**: a component stores a + serializable `AssetRef` (= `Option`); `AssetRef::resolve(db, + server)` yields the process-local `Handle` 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 ` +- **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::` (needs `Default`); enums via `register_enum::`. +- 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"); // 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` 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 ` 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). diff --git a/Home.md b/Home.md index a3e8842..55ac608 100644 --- a/Home.md +++ b/Home.md @@ -1 +1,63 @@ -Placeholder. Replaced by the first push. \ No newline at end of file +# 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 0–16 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. diff --git a/Input.md b/Input.md new file mode 100644 index 0000000..22f805a --- /dev/null +++ b/Input.md @@ -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::(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::("input.bindings"); + +// User remap → write into Settings → export to disk. +actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]); +*settings.get_mut::("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::("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::("input.bindings"); +*settings.get_mut::("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. diff --git a/Layers.md b/Layers.md new file mode 100644 index 0000000..19c0f15 --- /dev/null +++ b/Layers.md @@ -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 diff --git a/Math.md b/Math.md new file mode 100644 index 0000000..cec69a3 --- /dev/null +++ b/Math.md @@ -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 Gribb–Hartmann method; works for perspective and orthographic +projections alike. + +```rust +use oxide_engine::prelude::*; + +let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0); +let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y); +let frustum = Frustum::from_view_projection(proj * view); + +let visible_point = frustum.contains_point(Vec3::ZERO); + +let bb = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0)); +let visible_box = frustum.intersects_aabb(&bb); + +let visible_sphere = frustum.intersects_sphere(Vec3::ZERO, 1.0); +``` + +`intersects_aabb` is **conservative**: it never culls a box that is actually +visible, though it may very rarely keep one that is just outside a corner. That +is the correct trade-off for rendering, where false positives cost a wasted draw +but false negatives cause visible pop-out. + +--- + +## Color + +Linear RGBA color with `f32` channels. The engine works in **linear** space; +conversions to/from 8-bit sRGB are explicit. Values may exceed `1.0` to represent +HDR/emissive intensity and are not clamped in storage. + +```rust +use oxide_engine::prelude::*; + +let white = Color::WHITE; +let custom = Color::rgba(0.2, 0.4, 0.6, 1.0); +let from_pdf = Color::from_srgb_u8(135, 206, 235); // sRGB bytes → linear +let from_hex = Color::from_hex(0x87CEEB); // #87CEEB → linear + +let bytes = custom.to_srgb_u8(); // [u8; 4] sRGB, clamped to [0,1] +let v4 = custom.to_vec4(); // Vec4 [r,g,b,a] +let v3 = custom.to_vec3(); // Vec3 rgb +let faded = custom.with_alpha(0.5); +let mid = Color::BLACK.lerp(Color::WHITE, 0.5); // t clamped to [0,1] +``` + +Constants: `BLACK`, `WHITE`, `RED`, `GREEN`, `BLUE`, `TRANSPARENT`. + +--- + +## Rect + +A 2D axis-aligned rectangle for UI, viewports, and texture regions. + +```rust +use oxide_engine::prelude::*; + +let r1 = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0)); // corners auto-sorted +let r2 = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0)); +let r3 = Rect::from_center_size(Vec2::ZERO, Vec2::splat(2.0)); + +let (w, h) = (r2.width(), r2.height()); +let size = r2.size(); +let area = r2.area(); +let c = r2.center(); +let empty = Rect::ZERO.is_empty(); + +let inside = r2.contains_point(Vec2::ONE); +let hit = r2.intersects(&r3); +let i = r2.intersection(&r3); // Rect::ZERO if disjoint +let u = r2.union(&r3); +let near = r2.closest_point(Vec2::new(5.0, -1.0)); +let grown = r2.expanded(1.0); // negative shrinks +``` + +--- + +## Range3 + +A 3D **value** range — an inclusive `[min, max]` interval per axis. Unlike +`Aabb` (which models geometry), `Range3` models a *value range* for clamping, +interpolation, and remapping. It deliberately provides `lerp`/`inverse_lerp`/ +`remap`, which an `Aabb` does not. + +```rust +use oxide_engine::prelude::*; + +let r = Range3::new(Vec3::ZERO, Vec3::splat(100.0)); +let unit = Range3::UNIT; // [0, 1] per axis +let sym = Range3::symmetric(Vec3::splat(2.0)); // [-2, 2] per axis + +let span = r.span(); +let center = r.center(); +let clamped = r.clamp(Vec3::new(-5.0, 50.0, 200.0)); // → (0, 50, 100) +let inside = r.contains(Vec3::splat(50.0)); + +let v = r.lerp(Vec3::splat(0.25)); // NOT clamped — extrapolates outside [0,1] +let t = r.inverse_lerp(Vec3::splat(25.0)); // → 0.25 per axis (0.0 on zero-span axes) +let remapped = r.remap(Vec3::splat(50.0), &unit); // 50 in [0,100] → 0.5 in [0,1] +``` + +`inverse_lerp` guards against division by zero: an axis with zero span yields +`0.0` rather than `NaN`/`inf`. + +--- + +## Testing and performance + +- **Unit tests** for every type live in that type's source file + (`engine/src/math/*.rs`), covering core behavior and edge cases (zero scale, + gimbal lock, degenerate rays/planes, empty boxes, zero-span ranges). +- **Integration / fuzz tests** live in the `oxide-tests` crate + (`stage1::fuzz_transform_chains_stay_stable` builds long random transform + chains and verifies stability and inverse round-trips). +- **Benchmark:** `cargo bench -p oxide-engine` runs the `transform` benchmark. + The Stage 1 budget — 1,000,000 transform compositions in under 10 ms — is met + with margin (~5 ms on a typical desktop). + +## Runnable example + +```sh +cargo run -p oxide-examples --bin math_demo +``` + +`examples/src/bin/math_demo.rs` exercises every type above and prints the +results — a good place to see the API in use end to end. diff --git a/Modules.md b/Modules.md new file mode 100644 index 0000000..eacba01 --- /dev/null +++ b/Modules.md @@ -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::().unwrap() += 1; +assert_eq!(app.get_resource::(), 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::>() { + if let Some(mut t) = app.scene.get_mut::(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::().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 diff --git a/Physics.md b/Physics.md new file mode 100644 index 0000000..0b62a3c --- /dev/null +++ b/Physics.md @@ -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::()`. + +### 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::().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::().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 diff --git a/Play-mode.md b/Play-mode.md new file mode 100644 index 0000000..c52a2ed --- /dev/null +++ b/Play-mode.md @@ -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(®istry); // capture +// … play-mode mutations … +let scene = snap.restore(®istry)?; // 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. diff --git a/Prefabs.md b/Prefabs.md new file mode 100644 index 0000000..450a071 --- /dev/null +++ b/Prefabs.md @@ -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"); + +// 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 diff --git a/Projects.md b/Projects.md new file mode 100644 index 0000000..f5e2246 --- /dev/null +++ b/Projects.md @@ -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 diff --git a/Reflection.md b/Reflection.md new file mode 100644 index 0000000..d42c3dd --- /dev/null +++ b/Reflection.md @@ -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"); +registry.register::("Node"); +``` + +`register::(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"); +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"); +# registry.register::("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::("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"); +# 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"); +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 diff --git a/Render-context.md b/Render-context.md new file mode 100644 index 0000000..aa7615a --- /dev/null +++ b/Render-context.md @@ -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` 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. diff --git a/Render-pipeline.md b/Render-pipeline.md new file mode 100644 index 0000000..5e65519 --- /dev/null +++ b/Render-pipeline.md @@ -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::(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 diff --git a/Rendering.md b/Rendering.md new file mode 100644 index 0000000..b6a82f2 --- /dev/null +++ b/Rendering.md @@ -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.1–1000 range +let view = Transform::looking_at(Vec3::new(4.0, 2.5, 5.0), Vec3::ZERO, Vec3::Y); +``` + +The projection uses a `0..1` NDC depth range (the wgpu/Vulkan/DX/Metal +convention), matching the depth buffer the forward renderer clears to `1.0`. + +## Drawing a frame + +The `ForwardRenderer` is built once for a given **color target format** — the +window surface format for on-screen rendering, or e.g. `Rgba8Unorm` offscreen. +Then each frame you hand it a list of `RenderObject`s: + +```rust +// Once (e.g. lazily on the first frame, when the surface format is known): +let mut renderer = ForwardRenderer::new(device, ctx.surface_format); + +// Each frame, inside `App::render`: +renderer.render( + device, + queue, + ctx.view, // the target view (already cleared to the clear color) + ctx.size, // (width, height) in physical pixels + &camera, + &view, // the camera's world transform + &Lighting::default(), + &[ + RenderObject { mesh: &gpu_plane, material: Material::diffuse(Color::WHITE), transform: ground }, + RenderObject { mesh: &gpu_cube, material: Material::diffuse(Color::RED), transform: spin }, + ], +); +``` + +The color attachment is **loaded, not cleared**, so whatever cleared the surface +beforehand (the window's clear color from Stage 2, or a `clear_view` call) shows +through as the background. The depth buffer is owned by the renderer, resized to +match the target, and cleared to `1.0` every call. + +See the full runnable example: + +```sh +cargo run -p oxide-examples --bin hello_mesh # spinning cube + sphere + ground +``` + +## How it works + +- **One pipeline, one pass.** Geometry is drawn front-to-back-agnostic; a + `Depth32Float` depth buffer with `Less` compare resolves occlusion, so draw + order does not affect the result. +- **Per-object data via dynamic uniform offsets.** Globals (view-projection, + camera position, light) live in one uniform buffer (bind group 0). Each + object's model matrix, normal matrix, and material live in a second uniform + buffer addressed with a dynamic offset (bind group 1), so an arbitrary number + of objects draw from one buffer that grows as needed. +- **PBR-lite shading.** [`shaders/lit.wgsl`](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). diff --git a/Roadmap.md b/Roadmap.md new file mode 100644 index 0000000..f42697d --- /dev/null +++ b/Roadmap.md @@ -0,0 +1,1230 @@ +# Oxide Engine — Long-Term Development Plan + +Each stage must be completed and signed off (manual + automated testing) before the next begins. +Stages are designed so that each one produces a usable, standalone artifact that later stages build upon. + +The **in-engine editor** (`oxide-editor`) is a first-class deliverable. It is developed in parallel with +the engine: each stage adds editor support for the systems introduced in that stage. +The editor crate lives at `editor/` and is always buildable alongside the engine. + +The project must remain installable as a Linux package at all times. `install.sh` builds a release +binary and installs it to the system. Keep it current whenever new binaries or assets are added. + +--- + +## Development Phases + +Oxide is built in two phases: + +- **Phase 1 — The general-purpose engine (Stages 0–16).** Everything needed to build *and ship* any + 3D game: core framework, editor + project system, input, UI, physics, scripting, animation, + particles, shaders, audio, a content kit, and game export to Linux + Windows. Stage 16 is the + **milestone**: a complete, general-purpose engine that can build and export real games. +- **Phase 2 — Built-in modules (Stages 17+).** Optional, self-contained capabilities — ray-traced + audio, a developer console, a procedural toolkit, terrain, open-world streaming, pathfinding/AI, + water — each shipped as a feature-gated **module** built entirely on Phase-1 systems. Phase 2 is + also the proof the module API holds: adding a capability should mean *writing a module*, not + surgery on the core. + +The headline framing is simply **a general-purpose 3D engine that scales from stylized low-poly to +realistic, shipping only what each game uses.** Simulation, open world, and procedural generation are +*capabilities the engine supports through modules*, not design drivers — the goal is to **build +tools, not games**. + +--- + +## Platform & Target Strategy + +These are project-wide constraints that span every stage, not a single deliverable: + +- **Primary platform is Linux**, and Linux means **both Wayland and Xorg (X11)** — the editor and + any engine app must run on either without code changes. This is the default and must stay working + at every stage (verified via `winit`'s `wayland` + `x11` backends, both enabled). +- **Windows support comes later** — once enough of the engine exists to be worth porting, a compiled + Windows build of the editor/engine is added. Code should avoid Linux-only assumptions so this port + stays cheap; `wgpu`/`winit` already abstract the platform layer. +- **Game export targets both Linux and Windows binaries.** A project built in the editor must be + exportable to standalone runnable binaries for both platforms (see Stage 16). The editor and the + exported game share the same engine runtime. + +## Cross-Cutting Principles (apply to every stage) + +- **Build tools, not games.** The engine ships composable building blocks (e.g. noise + modifiers, + not a "make me a world" button). Genre-specific behavior belongs 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. The same engine produces a tiny stylized game and a heavy realistic + one, differing only in which modules are present. +- **Scalable fidelity.** The renderer is a **data-driven pass pipeline** so a project can run a flat + unlit/low-poly look (or a stylized post effect like a VCR filter) or a full realistic stack with + shadows, SSAO, and bloom — paying only for the passes it enables. +- **Modules are the primary extension point.** A module registers engine logic (systems, components, + asset loaders) **and** editor integration (panels, menu items, tools, inspectors) **and** its own + settings, through one documented API. Anyone — including AI agents — can write a module. Native + modules are Rust crates implementing the `Module` trait; lightweight modules can be `rhai` scripts. +- **Editor- and script-editable types.** Engine component types (e.g. `Transform`, `Node`, `Script`, + materials, colliders) must be editable **both** from the editor UI **and** from scripts/code, + through the same reflected/serializable representation. From Stage 5 this is backed by a reflection/ + type registry, so any editor — or an **AI agent in the editor terminal** — can create and edit game + code and scene data in real time. New component types are added with this dual-editability built in. +- **Live reload by default.** Game scripts/assets are watched on disk; changes are picked up and + applied to the running editor without a restart (watcher foundation in Stage 6, full script reload + in Stage 10). Assets added earlier keep their loaders reload-friendly. +- **The editor grows every stage.** Each stage adds editor support for its systems through the + Stage-6 editor framework: a top menu, dockable panels, an undo/redo command stack, a module-driven + extension API, and a **settings/preferences framework** that every system contributes pages to + (engine prefs, editor prefs, input bindings, per-module settings, enable/disable modules). +- **Choice of subsystems.** Where the engine offers an advanced system it also offers a standard one + so each project can choose its trade-off — most concretely **audio**: a standard mixer/spatial + system (Stage 14, core) *and* a ray-traced propagation module (Stage 17), sharing components. +- **Comprehensive, not minimal, gameplay systems.** Physics, input, and the UI are first-class, full + systems (Stages 7, 8 & 9), not thin wrappers — they must cover the cases real games need. +- **Document as you build, including how to extend.** A stage is not complete until its systems are + documented under `docs/`. Every module ships documentation for **both** how to *use* it **and** how + to *create* one like it (an authoring guide), so the module ecosystem is approachable. + +--- + +## Status + +| Stage | Title | State | +|-------|-------|-------| +| 0 | Project Foundation | ✅ Complete | +| 1 | Math & Core Primitives | ✅ Complete | +| 2 | Window & Render Context | ✅ Complete | +| 3 | Scene Graph & Entity System | ✅ Complete | +| 4 | Basic 3D Rendering | ✅ Complete | +| **Phase 1 — General-purpose engine** | | | +| 5 | Engine Core Framework (modules, layers, assets, reflection, render graph) | ✅ Complete | +| 6 | Editor Framework & Project System | ✅ Complete | +| 7 | Input System (mapping + remappable actions) + gizmos | ✅ Complete | +| 8 | Comprehensive UI System | ✅ Complete (pieces 1–8 on `main`; the editor UI canvas — piece 9 — shipped as Stage 8.5 piece 7) | +| 8.5 | Reflection v2, Editor Redesign & Asset Database (foundation) | ✅ Complete — every component type is editor- and script-editable with no per-type code; asset database + UI canvas on `main` | +| 8.7 | Editor Play Mode (Play/Pause/Step/Stop) | ✅ Complete on `main` — snapshot/restore + play-state model, `App::step`/`tick_for`, toolbar + Ctrl+P/Ctrl+. + viewport tint, scene-swap host runner (eye-checked & approved 2026-06-16) | +| 9 | Physics Integration (comprehensive) | ✅ Complete on `main` — full sim/events/queries/joints/character controller + 2 examples + editor types/play-loop/collider gizmos + raycast debug probe (freeze-on-click; eye-checked & approved 2026-06-17) | +| 10 | Scripting, Live Reload & Editor Terminal | 🚧 Core done on `main` (eye-checked & approved 2026-06-17): `oxide-script` crate (`Script`, `.rhai` loader, sandboxed `ScriptEngine`, `ScriptModule`) + lifecycle (`init`/`update(dt)` via `ScriptHost`) + `Vec3`/transform engine API + **live reload** (`examples/script_spin`) + **editor integration** (`Script` addable; play-loop shares asset server/db → live `.rhai` edits update a *playing* scene) + **Console** (captures the `log` stream — script `print`/errors) + **command terminal** ($ → `sh -c`) + **interactive PTY terminal** (`portable-pty`+`vt100`, tabbed, auto-close on exit, Tab/arrows/Esc routed; runs shells/TUIs/`claude`). **Remaining:** richer script API (spawn/despawn + component add/edit beyond `Transform`); then the editor-UX batch (file explorer, native New/Open dialog, New-Script button, open-script-in-editor) | +| 11 | Animation System | ⬜ Planned | +| 12 | Particle System | ⬜ Planned | +| 13 | Shader System & Advanced Graphics | ⬜ Planned | +| 14 | Standard Audio System | ⬜ Planned | +| 15 | Engine Content & Prototyping Kit | ⬜ Planned | +| 16 | Windows Support & Game Export | ⬜ Planned — **Phase 1 milestone** | +| **Phase 2 — Built-in modules** | | | +| 17 | Ray-Traced Spatial Audio (module) | ⬜ Planned | +| 18 | Developer Console & Cheats (module) | ⬜ Planned | +| 19 | Procedural Toolkit (module) | ⬜ Planned | +| 20 | Terrain System (module) | ⬜ Planned | +| 21 | Open World Support (module) | ⬜ Planned | +| 22 | Pathfinding & NPC AI (module) | ⬜ Planned | +| 23 | Water System (module) | ⬜ Planned | + +> Backlog (post-roadmap capabilities and additional modules) is at the end of this file. + +--- + +## Stage 0 — Project Foundation ✅ +**Goal:** A buildable, testable Rust project with core structure in place. + +### Deliverables +- Cargo workspace layout (`engine/`, `editor/`, `examples/`, `tests/`) +- Basic logging and error handling infrastructure +- CI-ready: `cargo build`, `cargo test`, `cargo clippy`, `cargo fmt` all pass +- Empty integration test harness +- `README.md` with engine description, build instructions, and install instructions +- `install.sh` — builds release binaries and installs to `/usr/local` (or `$PREFIX`) +- Editor stub: `oxide-editor` binary that starts, logs, and exits cleanly + +### Test Criteria +- `cargo build` succeeds from clean checkout +- `cargo test` runs (even with zero tests) without error +- Clippy reports zero warnings with `deny(warnings)` +- `install.sh` completes without errors on a clean Linux system +- `oxide-editor` binary runs after installation + +--- + +## Stage 1 — Math & Core Primitives ✅ +**Goal:** Solid math foundation every other system will depend on. + +### Deliverables +- Choose and integrate math library (`glam` preferred) — done (`glam` with `serde`) +- `Transform` type: position, rotation (quaternion), scale — `engine/src/math/transform.rs` +- `AABB`, `Ray`, `Plane`, `Frustum` types — `engine/src/math/{aabb,ray,plane,frustum}.rs` +- Common utility types: `Color`, `Rect`, `Range3` — `engine/src/math/{color,rect,range3}.rs` +- Unit tests for all types (edge cases: zero scale, gimbal lock paths, etc.) — 69 unit tests +- **Editor:** no visible UI yet; math types available via `oxide_engine::math` / `prelude` + +### Test Criteria +- 100% unit test coverage on all math types — ✅ every type has a `tests` module +- Fuzz transform composition (random chains should remain stable) — ✅ `tests/` `stage1::fuzz_transform_chains_stay_stable` +- Benchmark: 1M transform multiplications under 10ms — ✅ ~5.1ms (`cargo bench -p oxide-engine`) + +### Notes +- `Transform` stores decomposed TRS; composition takes an exact fast path for + uniform scale and re-decomposes from the matrix for the non-uniform case. +- `Range3` is a *value* range (clamp/lerp/remap), distinct from `Aabb` (geometry). +- Runnable example: `cargo run -p oxide-examples --bin math_demo`. + +--- + +## Stage 2 — Window & Render Context ✅ +**Goal:** A window opens, GPU context is acquired, a colored screen is displayed. + +### Deliverables +- `winit` integration for window creation and event loop — `engine/src/window/` (`App` trait, `AppCtx`, `WindowConfig`, `run`) +- `wgpu` device/queue/surface setup — `engine/src/render/` (`Gpu`, `RenderContext`) +- Clear-color render loop (configurable clear color) — `render::clear_view`, `AppCtx::set_clear_color` +- Resize handling — `RenderContext::resize`, driven by the event loop; minimized windows clamp to 1×1 +- Input event plumbing (keyboard, mouse — raw events only, no abstraction yet) — every `WindowEvent` forwarded to `App::event`; types re-exported via `window::event` +- **Editor:** opens its own window using this infrastructure; displays a placeholder viewport (dark clear, raw input logged, `Ctrl+Q` quits) + +### Test Criteria +- Window opens on Linux without errors — ✅ manual sign-off (2026-06-11, Wayland, RADV/Vulkan) +- Resize does not crash or produce artifacts — ✅ manual sign-off +- Clear color changes are reflected immediately — ✅ manual sign-off (`hello_window` keys 1–5/Space) +- Manual: run the `hello_window` example and confirm stable 60+ FPS on minimal GPU — ✅ ~3700 FPS (RX 9070 XT, Mailbox present mode) +- Automated: headless GPU clear verified by pixel readback — ✅ `tests/` `stage2::headless_clear_fills_texture_with_clear_color` + +### Notes +- Docs: [Windowing](Windowing), [Render-context](Render-context). +- `wgpu`/`winit` are re-exported (`oxide_engine::wgpu` / `::winit`) so consumers + don't need to version-match their own copies. +- egui editor panels intentionally deferred to Stage 3 (Stage 2 editor scope is + the placeholder viewport only). + +--- + +## Stage 3 — Scene Graph & Entity System ✅ +**Goal:** Entities with transforms exist in a hierarchy; scene can be queried and mutated. + +### Deliverables +- Choose and integrate ECS — done (`hecs`, re-exported as `oxide_engine::hecs`) +- `Scene` type: add/remove/query entities — `engine/src/scene/graph.rs` +- `Transform` hierarchy (parent-child with world-space resolution) — `Scene::world_transform` / `world_transforms` +- `Node` component: name, enabled flag — `engine/src/scene/node.rs` +- Scene serialization/deserialization (RON) — `Scene::to_ron` / `from_ron`, stable index-based format +- `examples/scene_basic`: create a hierarchy, print world transforms — done +- **Editor:** scene hierarchy panel; select, rename, enable/disable, reparent nodes — done (egui Hierarchy + Inspector panels) + +### Test Criteria +- Parent transform propagates correctly to children — ✅ unit + `tests/` `stage3` +- Removing a parent removes or detaches children (configurable) — ✅ `DespawnPolicy::{Recursive, DetachChildren}` +- Serialize → deserialize round-trip produces identical scene — ✅ byte-identical re-serialization +- 10,000 entity scene with 5-level deep hierarchy resolves in under 1ms — ✅ ~0.6ms (`cargo bench -p oxide-engine --bench scene`) +- Manual: editor hierarchy/inspector panel verified by maintainer (2026-06-11, Wayland, RADV/Vulkan) + +### Notes +- Docs: [Scene](Scene). +- The hierarchy is owned by `Scene` (ordered `roots`/`children`), not stored as + components, for deterministic ordering and cheap reparenting; the ECS still + owns all entity data, so later stages attach their own components. +- egui is an **editor-only** dependency: the engine exposes a generic post-clear + draw hook (`App::render(&RenderCtx)`) and the editor's `EguiLayer` draws + through it, keeping egui out of the engine's dependency tree. + +--- + +## Stage 4 — Basic 3D Rendering ✅ +**Goal:** Meshes are loaded and rendered with a simple material system. + +### Deliverables +- `Mesh` type: vertex buffer (position, normal, UV), index buffer +- GLTF loader (static meshes only, no animation yet) +- Forward renderer: depth buffer, basic lit pass +- `Material` type: albedo color/texture, roughness, metallic (PBR-lite) +- Camera component with perspective projection +- `examples/hello_mesh`: load a GLTF cube/sphere and render it +- **Editor:** 3D viewport renders the active scene; camera orbit/pan/zoom controls; material inspector panel + +### Test Criteria +- GLTF primitives (cube, sphere, plane) render correctly +- Depth ordering is correct (no z-fighting on simple scenes) +- Camera movement updates the view matrix correctly +- Manual: inspect rendered output for correct lighting on a sphere — ✅ maintainer sign-off (2026-06-12) + +### Notes +- Docs: [Rendering](Rendering). +- The forward renderer has automatic render-backend fallback + (Vulkan/Metal/DX12 → GL → software) so it runs on a wide range of GPUs. +- The hardcoded forward pass from this stage is generalized into a data-driven + **render pass pipeline** in Stage 5 — later passes (shadows, post-process, + overlay UI) compose into it rather than rewriting the renderer. + +--- + +## Stage 5 — Engine Core Framework +**Goal:** The structural "spine" every later system plugs into: a module system, scene/object +**layers & tags**, a central **asset server**, a minimal **reflection/type registry**, and a +**data-driven render pass pipeline**. Built now, while the engine is small, so later stages add +capabilities without rewriting the core. + +### Deliverables +- **Module system + app builder.** An `App`/`Engine` assembled by registering `Module`s. A + `Module::build(&mut App)` hook registers: update systems (with an explicit ordering/schedule), + component types, asset loaders, and (via Stage 6) editor integration. `DefaultModules` bundles the + built-in set. Math/window/render/scene are wrapped as built-in modules; subsystems from Stage 9 on + are built as **their own feature-gated crates** (`oxide-physics`, `oxide-audio`, …) so an exported + game compiles in only what it registers. +- **System scheduling.** A simple, deterministic ordered schedule (e.g. `Input → PreUpdate → Update → + PostUpdate → Render`) that modules attach systems to, with a fixed-timestep slot reserved for + Stage 9 physics. +- **Layer & Tags.** + - **`LayerMask`** — a 32-slot bitmask with a **project-level named-layer registry**. One shared + definition consumed by physics collision groups + sensor filtering (Stage 9), **camera + visibility masks** (which layers a camera renders — first-person view models, editor-only gizmo + layers, minimap cameras), and **scene/raycast query filtering**, plus editor per-layer + show/hide/lock. + - **Groups/tags** — the *multi-valued* counterpart to the *single-valued* `Layer` (Unity model: an + entity is on one layer but in any number of groups). Membership lives in the `Tags` component; + the project's valid group names live in a `GroupRegistry` so the editor offers a fixed vocabulary + to pick from. Separate from the hot-path `LayerMask`. +- **Asset server + handles.** A central registry returning typed, ref-counted **handles** + (`Handle`, `Handle`, …) with async loading and a pluggable loader registry. Stage 4's + GLTF/texture loading is migrated onto it. This one system underpins live reload (Stage 10), + streaming (Stage 21), and export packing (Stage 16), so it lands before more loaders accrue. +- **Reflection / type registry (minimal).** Register a component type once and get serialization + (serde/RON), a generic editor inspector (Stage 6), and script access (Stage 10) — the backbone of + the *dual-editable types* principle. Existing hand-written inspectors are migrated as the registry + matures; every component type from Stage 7 on is dual-editable by construction. +- **Data-driven render pass pipeline.** Generalize Stage 4's forward pass into an ordered, composable + list of passes (depth, lit, transparent, post-process, overlay) with shared render targets. A + project/module enables only the passes it needs — this is the mechanism behind *scalable fidelity* + (a flat low-poly look vs. a full realistic stack) and the insertion point for Stage 13's + post-process effects and Stage 8's UI overlay. +- **Editor:** no new dedicated panel beyond wiring — the hierarchy/inspector now read the reflection + registry, the viewport gains a layer visibility toggle, and the groundwork is laid for Stage 6's + module/extension framework. + +### Delivered (all on `main`) +- ✅ **Layers, groups & tags** (`oxide_engine::layer`) — `LayerMask`, `LayerRegistry`, single-valued + `Layer`, multi-valued `Tags` + `GroupRegistry`. +- ✅ **Reflection/type registry** (`oxide_engine::reflect`) — generic name-keyed component access. +- ✅ **Asset server + handles** (`oxide_engine::asset`) — ref-counted `Handle`, dedup, loaders, + background load, in-place reload; glTF migrated onto it. +- ✅ **Module system + scheduling** (`oxide_engine::app`) — `App` + `Module` + `Schedule` + (phases incl. fixed-timestep), enable/disable/remove; `CoreModule`/`RenderModule`/`DefaultModules`. +- ✅ **Data-driven render pass pipeline** (`oxide_engine::render::RenderPipeline`) — composable + `RenderPass` list, `ClearPass`/`ForwardPass`, camera `LayerMask` visibility; editor viewport + + `hello_mesh` retrofitted. Maintainer manual sign-off 2026-06-12 (viewport + `hello_mesh` render + identically). + +> **Known tech-debt (tracked):** there are now two `App` types — the engine container +> `oxide_engine::app::App` and the windowing `oxide_engine::window::App` trait (the per-window event +> handler). To avoid the clash the core `App` is intentionally **not** in the prelude (import it as +> `oxide_engine::app::App`); `window::App` stays in the prelude. Resolve by renaming the windowing +> trait (e.g. `WindowApp`/`AppHandler`) when the editor adopts the core `App` loop in Stage 6/7 — a +> GUI-affecting change best done with a manual-test pass, not in isolation. + +### Test Criteria +- An app composed purely by registering modules runs; removing a module removes its systems, + components, and assets with no dangling references +- A feature-gated module excluded at compile time produces a binary with none of its code +- `LayerMask` filtering is correct and shared: the same named layer drives a render visibility mask + and (stubbed) a query filter identically +- The asset server returns ref-counted handles; loading the same asset twice yields one resource; + dropping all handles frees it +- A component registered with the reflection registry round-trips through RON and is read/written + generically (proving the inspector/script path) without type-specific code +- The render pipeline runs Stage 4's scene through the new pass list with identical output, and a + pass can be added/removed without touching the renderer core +- Benchmark: module/system scheduling overhead is negligible vs. the Stage 4 hardcoded loop + +--- + +## Stage 6 — Editor Framework & Project System ✅ +**Goal:** Turn the editor from a fixed set of panels into a proper, extensible application shell with +a **project system** — so every later stage (and every third-party module) plugs UI, tools, and +settings into a consistent host, and real projects can be created, opened, and saved. + +### Deliverables +- **Editor shell.** A top **menu bar** (File / Edit / View / Project / Window / Help / module menus), + a **dockable/resizable panel system**, status bar, and a registry of panels modules can add to. +- **Command / undo-redo stack.** A central, editor-wide command stack so every tool (transform + gizmos, inspector edits, and later sculpt/paint/scatter) is undoable/redoable through one + mechanism — landed here so all subsequent editor tools are undoable for free. +- **Module → editor extension API.** The Stage-5 `Module` gains an editor hook to contribute: menu + items, dockable panels, viewport tools/gizmos, component inspectors (via the reflection registry), + and settings pages. This is *the* mechanism by which "anyone can write a module" that extends both + engine logic and the editor. +- **Settings / preferences framework.** A unified, serialized settings system with pages contributed + by the engine, the editor, and each module: + - **Editor preferences** (theme, layout, viewport, shortcuts) and **engine preferences** + (render/quality defaults) — grown incrementally as later stages add their options. + - **Module management:** list installed modules, **enable/disable** them, and edit each module's + own settings page. + - Input bindings get their settings page here once Stage 7 lands. +- **Project system.** Create / open / save a **project**: a project file (RON) plus a defined asset/ + scene/script folder layout, a list of enabled modules and their settings, and per-project + preferences. A recent-projects list and a "new project" flow. Scenes load/save within a project. +- **File-watcher foundation.** Watch the project's asset/scene folders (`notify`) and reload changed + assets live — the groundwork for Stage 10's full script hot-reload and the "edit with any external + editor or AI agent" workflow. +- **Editor:** the existing hierarchy/inspector/viewport are reparented into the new docking shell; + a Project panel/asset browser; a Preferences window with the settings pages above. +- **Resolve the Stage-5 `App` naming clash.** As the editor adopts the engine's core + `oxide_engine::app::App` loop here, rename the windowing `window::App` trait (e.g. `WindowApp`) so + the core `App` can live in the prelude unambiguously (see the Stage-5 tech-debt note). + +### Test Criteria +- Panels can be docked, undocked, resized, and restored across restarts (layout persists) +- A trivial test module adds a menu item, a panel, and a settings page purely through the extension + API — with no edits to editor core +- Undo/redo correctly reverses and re-applies a sequence of edits (e.g. rename, reparent) through the + single command stack +- Creating, saving, closing, and reopening a project restores its scenes, enabled modules, and + settings unchanged (RON round-trip) +- Enabling/disabling a module in settings adds/removes its systems and editor contributions live +- Editing a watched asset file on disk updates the running editor without a restart +- Manual: maintainer creates a project, rearranges the layout, toggles a module, and reopens it + +--- + +## Stage 7 — Input System (mapping + remappable actions) + gizmos ✅ +**Goal:** A comprehensive input abstraction used by all higher-level systems, with full per-key +edge detection and a remappable named-action layer — and the editor's viewport transform gizmos, +built on the Stage-6 command stack. + +**Status (2026-06-14):** ✅ Complete. Shipped in six pieces — `InputState` (1), `ActionMap` / +`Binding` (2), axis + 2D-axis actions (3), editor flythrough camera (4), input-bindings +preferences page with disk persistence (5), gizmo math + viewport tab sizing + viewport +gizmos (6a/6b/6c). Final maintainer test surfaced three issues (snap mid-drag, uniform-scale +sensitivity, sphere winding) — all fixed in the same stage. Gamepad bindings are deferred +("where available" in the deliverables); the rest is in. + +### Deliverables +- `InputState`: keyboard, mouse button, mouse delta, scroll, gamepad (where available) +- **Per-key edge + state queries** for any physical key/button: `pressed` (this frame), + `released` (this frame), and `held`/`down` (current state) — i.e. map any key to its press, up, + and down events. +- **Named-action mapping with defaults and remapping.** Bind named actions (e.g. `"Jump"`) to one or + more physical inputs, each action carrying a **default** binding (e.g. `Space`). Game code queries + only the action name (`input.action_pressed("Jump")`) and never the physical key, so a project's + settings screen can remap `"Jump"` to any other key without touching game code. +- **Bindings persistence**: action maps load/save (RON) so game settings can store user remaps, + surfaced through the Stage-6 settings framework. +- Input consumed/propagated model (UI/editor can consume before game) +- Axis/2D-axis actions (e.g. movement) composed from keys and/or gamepad sticks +- `examples/input_debug`: print all actions and raw input each frame; `examples/input_remap`: + rebind an action at runtime and show game code unaffected +- **Editor:** input bindings editor page (assign/clear/remap action bindings, restore defaults) in + the settings framework; live input-state debug overlay; **viewport flythrough camera** (WASD/QE + move, mouse-look, with Shift to accelerate) layered on top of the existing orbit/pan/zoom controls +- **Editor transform gizmos:** on-screen manipulators for the selected entity — **translate** (axis + arrows + plane handles), **rotate** (axis circles), and **scale** (axis/uniform handles) — dragged + directly in the viewport, every edit recorded on the Stage-6 undo stack. Includes a **snap system** + with configurable increments (grid distance, angle step, scale step) and a modifier key to toggle + snapping; switch active tool with hotkeys (e.g. W/E/R). With gizmos in place, the inspector's + transform fields become **directly type-editable** (click to type an exact value), not drag-only. + +### Test Criteria +- `pressed`/`released` fire exactly once on the correct frame; `held` reflects current state +- Dragging each gizmo axis moves/rotates/scales the selection on exactly that axis; snapping + constrains the result to the configured increment; typed inspector values apply exactly; each is + undoable/redoable through the command stack +- Action bindings can be changed at runtime and persisted/restored via RON +- Multiple keys can be bound to one action; one key can drive multiple actions +- Remapping an action changes behavior while game code (querying the action name) is unchanged +- Mouse delta is correct across frame boundaries (no jump on first frame) + +--- + +## Stage 8 — Comprehensive UI System + +**Status (2026-06-15): 🟡 In progress.** Pieces 1–7 complete and on `main`: + +1. ✅ Widget tree + layout (`oxide_engine::ui::{widget, style, layout}`). +2. ✅ Styling & theming (`visual`, `theme`; cascade default → named → per-instance). +3. ✅ Text shaping + glyph atlas (`text/{font, atlas, shape}` via `ab_glyph`). +4. ✅ 2D overlay render pass — screen-space (4a) + world-space `UiPanel` (4b). +5. ✅ Input routing (`routing::Router`; hit-test + hover / press / focus state machine). +6. ✅ Events + data binding (immediate-mode `RouterFrame::clicked_left(...)` etc. + typed `WidgetValue`). +7. ✅ `examples/ui_menu` — themed main menu + draggable slider + checkbox settings. + +Remaining for sign-off: + +8. ✅ `examples/ui_hud` (health / ammo / minimap overlay + centre crosshair, composited over + the Stage-4 forward pass) — signed off on `main` 2026-06-15 after maintainer eye-check. The + atlas cache-hit claim it demonstrates is also covered by an automated GPU test + (`render::ui_pass::atlas_caches_glyphs_and_reaches_steady_state`). +9. ⬜ Editor **UI canvas** — visual document builder. + +**Goal:** A first-class, **in-game** UI system — widgets, layout, styling, text, and input routing — +that ships inside exported games. This is distinct from the editor's `egui` (which stays +editor-only): this UI is an engine system a project uses to build its menus, HUDs, and tools, and it +consumes input through the Stage 7 model and renders through the Stage 5 pass pipeline. + +### Deliverables +- **Widget tree** owned by the engine and renderable in shipped games: containers/panels, label/ + text, button, image, checkbox, radio, slider, drop-down, text input, progress bar, and a + scrollable list/view +- **Layout system**: stack (row/column), grid, and anchor/dock layouts; padding/margin, alignment, + and sizing modes (fixed / grow / fit-content); resolution- and **DPI-aware** scaling +- **Styling & theming**: per-widget styles plus reusable themes (colors, fonts, spacing, borders), + overridable per instance +- **Text rendering**: TTF font loading, a glyph atlas, alignment, wrapping, and multi-font support +- **Input integration**: UI consumes input *before* the game using Stage 7's consume/propagate model; + hover/focus/press states; keyboard/gamepad focus navigation. (The Stage 7 bindings/remap settings + screen is itself buildable with this UI system.) +- **Events & data binding**: widget callbacks/events and binding widget values to game data +- **2D overlay pass** integrated as a Stage-5 render pass (batched draws), plus optional + **world-space UI** (a UI panel rendered on a quad in 3D) +- **Serializable, dual-editable UI documents** (RON): layouts round-trip through the engine's + representation so they are authored in the editor *and* editable from scripts/AI agents +- `examples/ui_menu` (main menu + settings, including action remapping) and `examples/ui_hud` + (health/ammo/minimap overlay) +- **Editor:** a visual **UI canvas** to build and arrange UI documents — widget palette, drag/resize, + property inspector, and live preview — saving the same RON the runtime loads + +### Test Criteria +- Widgets render and lay out correctly across window sizes and DPI scale factors +- Input routing is correct: UI consumes clicks/keys over its widgets, and the game receives input + only where the UI does not consume it +- Hover/focus/press states and keyboard/gamepad focus navigation behave correctly +- Text renders crisply with correct wrapping and alignment; the atlas handles large glyph sets +- A UI document round-trips through RON unchanged (proving dual-editability) +- Manual: build a menu in the editor's UI canvas, run an example, and interact with it (including + remapping an action from the settings screen) + +--- + +## Stage 8.5 — Reflection v2, Editor Redesign & Asset Database (foundation) + +**Status: ✅ Complete (2026-06-16).** All pieces (1–7) on `main`; piece 7c (UI canvas) eye-checked +& approved. Inserted before Stage 9 (and before Stage 8's deferred piece 9) because every later stage +adds component types (colliders, animation clips, particle emitters, audio sources, …) and each one +would otherwise need a hand-written editor inspector and hand-wired add/remove plumbing. Doing this +**now** was the cheapest it would ever be — it stops that per-type cost from accruing across Stages +9–16. + +> **Known minor follow-up (UI canvas):** egui logs `Widget rect … changed id between passes` while +> interacting with the canvas — id instability from dynamically-sized property widgets across egui's +> two-pass layout. Harmless (egui recovers each frame) but noisy; quiet it by scoping the canvas's +> per-widget sections under stable `ui.push_id(...)`s. Low priority. + +**Why:** Stage 5 shipped a *whole-value* reflection registry (`oxide_engine::reflect` round-trips a +whole component as RON). The editor inspector, however, is still **hand-coded per type** +(`transform_inspector`, `mesh_inspector` in `editor/src/shell.rs`), and component add/remove is +bespoke per type (`PendingAction::AddMesh/RemoveMesh`). This finishes the project's **dual-editable +types** principle: a component's public fields should surface automatically in the inspector and over +scripts (Stage 10), the Unity/Godot model — *public mutable fields appear as editable inspector +fields with no per-type code.* + +### Component model — node-baked vs modular (2026-06-15 decision) + +The maintainer surfaced a distinction the editor must honor explicitly: + +- **Node-baked (essential):** every entity inherently carries them — they're + part of *being a node*, not a feature the user adds. Today: `Node`, + `Transform`, `Layer` (auto-attached on `Scene::spawn`). Always present, + single-instance, rendered in a fixed canonical order, **no** enable + checkbox, **no** remove button, **no** drag-reorder handle, **not** offered + by the Add Component menu. +- **Modular:** the user's choice — `MeshRenderer`, `Camera`, `DirectionalLight`, + future `RigidBody`, particle emitters, audio sources, custom scripts. Addable + from the registry, removable, disable-able, reorderable. + +The editor enforces this with `ESSENTIAL_COMPONENTS = ["Node", "Transform", +"Layer"]` (see `editor/src/shell.rs::is_essential_component`). Future +node-baked additions extend that list; modular ones just register through +`register_addable`. + +**Layers & groups in the inspector (2026-06-16 decision — Unity model "A").** +The single-valued `Layer` renders as a single-select dropdown (with a Layer +Names editor); gameplay **Groups** (multi-valued, backed by `Tags` + a +`GroupRegistry`) render as a multi-select dropdown (with a Groups editor). Both +sit in the node-baked section above the modular list. An archetypal ECS allows +only **one component of a given type per entity**, so a second mesh/collider/etc. +lives on a **child** entity (the Add Component menu's "as child" path); several +*distinct* addable types (`MeshRenderer`, `Camera`, `DirectionalLight`) is what +makes multi-component nodes and drag-reorder exercisable. + +**Multi-component-of-same-type** (e.g. two colliders, two meshes) is not +supported by archetypal ECS (`hecs` allows one per type per entity) and +**won't be lifted in Oxide**. The two principled patterns the engine offers: + +- **Child entities** (Bevy-style): each "additional" piece is its own + entity, parented to the owner. Composes with the existing hierarchy + + drag-drop reparent + per-child enable/disable. +- **Submeshes / multi-slot components** (Unity-style): a single component + wraps a list internally (e.g. a `Mesh` asset can have multiple submeshes + with their own material slots). + +Stage 9 (Physics) picks for colliders; Stage 13 (Shaders) may revisit for +meshes. The Add Component menu surfaces this clearly when a user tries to +add a duplicate ("Add Child with …" affordance — TODO in piece 4 polish). + +### Deliverables + +- **Reflection v2 — per-field reflection + a `#[derive(Reflect)]` macro.** A new + `oxide-engine-derive` proc-macro crate generates, for a struct's public fields, a field table + (name + type + typed get/set) re-exported from `oxide_engine`. `reflect.rs` grows from whole-value + to field-level access while keeping the existing name-keyed registry API. Pure logic, fully + unit-testable. +- **Generic reflection-driven inspector.** One inspector that walks a selected entity's registered + components and renders a widget per field by type (`f32`→drag, `bool`→checkbox, `Color`→picker, + `Vec3`→vec3 drag, enum→combo, `Handle`→asset picker), falling back to a RON text field for + unknown types. The hand-written `transform_inspector` / `mesh_inspector` are **deleted** and proven + equivalent. All edits route through the existing undo/redo command stack. +- **Editor redesign items.** Hierarchy **right-click context menu** (Add Child / Add prefab / Rename + / Duplicate / Delete); inspector **"Add Component ▾"** menu enumerated from the type registry; + remove-component control per component. (The deferred Stage-8 piece 9 — the visual UI canvas — lands + on top of this redesign so its property inspector is reflection-driven, not bespoke.) +- **Prefabs / archetypes (lightweight).** A named "spawn a thing that already carries these + components" entry so the hierarchy add-menu is data-driven (an entity is its component set; this is + not a parallel "object type" system). +- **Asset database + typed project folders.** `assets/` gains typed subfolders + (`fonts/`, `textures/`, `models/`, `audio/`, `ui/`); an asset database maps **project-relative + paths ↔ stable ids ↔ `Handle`** so scenes and UI documents reference `"fonts/Inter-Regular.ttf"` + rather than absolute system paths (a prerequisite for clean game export at Stage 16). The Project + panel becomes an asset browser; "import" = drop a file into the right folder and let the watcher + register it. A bundled default UI font lands here (resolves the Stage-8 follow-up). +- **`Handle` as a reflected field type** so the generic inspector renders an asset-picker filtered + by type — selecting a UI element then choosing its font is exactly this. +- **Docs:** [Reflection](Reflection) (per-field model + derive), [Assets](Assets) (database + folders + + picker), and an editor-redesign note; update `architecture.md`. + +### Piece breakdown (each = one green commit) + +1. ✅ `oxide-engine-derive` crate + `Reflect` trait (per-field name/type/get/set) + derive — pure + logic, unit-tested, on `main`. Public-only fields + `#[reflect(skip)]`; per-field RON get/set. +2. ✅ `reflect.rs` per-field get/set through the registry via `register_reflected::` + (`field_infos` / `get_field` / `set_field`); Transform + Node derive `Reflect`. Whole-value API + unchanged. Pure logic, on `main`. +3. ✅ Generic reflection-driven inspector (signed off on `main` 2026-06-15 after eye-check). Split: + - **3a**: `EditorState.registry` (Transform + Node reflected) + generic + `SetFieldCmd{entity,type,field,before,after}` with merge-coalescing. Pure logic. + - **3b**: the inspector UI — walks the registry, one heading per component, one typed widget per + field (f32→drag, bool→checkbox, Vec3→3 drags, String→text, `Quat`→Euler degrees, unknown→RON + fallback). Deletes the hand-written `transform_inspector`; `mesh_inspector` stays until piece 4. + `Node` skipped (name/enabled shown by the header). Enum→combo deferred (needs variant reflection). +4. Hierarchy redesign + component lifecycle (GUI) — → `dev`, eye-check: + - Hierarchy **right-click** context menu (Add Child / Add prefab / Rename / Duplicate / Delete). + - **Drag-and-drop reparenting in the hierarchy**, and **remove the inspector's Parent dropdown** + (maintainer request 2026-06-15: the dropdown lists every entity, so it doesn't scale to large + scenes — reparenting belongs in the tree via drag-and-drop). + - Inspector **"Add Component ▾"** (enumerated from the registry) + per-component remove control; + make `MeshRenderer` reflected and retire the bespoke `mesh_inspector`. + - Enum→combo widget (add variant reflection to `#[derive(Reflect)]` so enum fields like + `PrimitiveShape` get a dropdown instead of the RON fallback). +5. Prefab/archetype spawn entries (logic + a little GUI) — split: + - ✅ **5a** (engine logic, on `main`): `oxide_engine::prefab` — `ComponentSpec`/`Prefab`/ + `PrefabRegistry`, data-driven (component name + RON applied via the `TypeRegistry`), `spawn`/ + `spawn_child`/`unknown_specs`. Pure logic, unit-tested; [Prefabs](Prefabs). + - 🟡 **5b** (editor GUI, on `dev` — awaiting eye-check): `EditorState.prefab_registry` seeded + with built-in prefabs (Empty/Cube/Sphere/Plane/Camera/Directional Light); hierarchy add-menus + (toolbar Root/Child, row right-click Add Child, empty-area Add Root) are submenus listing them. + Plain `AddRoot`/`AddChild` retired (the `Empty` prefab covers a bare node). +6. Asset database + typed folders + `Handle` reflected + asset-picker widget (logic → `main`; + picker GUI → `dev`) — split: + - ✅ **6a** (engine logic, on `main`): `oxide_engine::asset::AssetDatabase` — typed `assets/` + subfolders (`AssetKind`: fonts/textures/models/audio/ui), stable `AssetUid` ↔ relative-path + manifest (`assets.manifest`), `scan`/`register`/`open`/`save`, and uid→`Handle` resolution + through the `AssetServer`. Unit-tested round-trip + moved-project case; [Assets](Assets). + - ✅ **6b** (engine logic, on `main`): `asset_ref_target` parses an `AssetRef`/`Handle` + field's syntactic `type_name` to its target type, and `AssetKind::for_handle_target` maps that + to the picker's filter kind. Added the serializable `AssetRef` reference type (the form a + component stores — `Option` that `resolve`s to a `Handle`). Pure logic, unit-tested. + - 🟡 **6c** (editor GUI, on `dev` — awaiting eye-check): `EditorState.asset_db` (opened + scanned + on project open/create, rescanned on watcher events); Project panel is an asset browser + (typed-folder sections from the database + Rescan); `field_widget` asset-picker for + `AssetRef`/`Handle` fields (lists assets of the matching kind, stores the `AssetUid`); + bundles **Inter** (SIL OFL) as the default UI font, seeded into new projects' `fonts/`. The + first component *using* an `AssetRef` field arrives with piece 7 (UI canvas). +7. Migrate Stage-8 piece 9 (UI canvas) onto the reflection-driven inspector — split: + - ✅ **7a** (engine logic, on `main`): widget-tree authoring primitives in `oxide_engine::ui` — + `WidgetPath` (positional addressing) + `Widget::{get_path,insert_child,remove_path,move_subtree}` + (the basis for the canvas's undoable add/remove/move). Unit-tested. + - ✅ **7b** (engine logic, on `main`): `FontLoader` (default-registered, makes `.ttf`/`.otf` + loadable via `AssetServer`) + `VisualStyle.font_asset: Option>` (the engine's + first `AssetRef` field; overrides the `FontRef` descriptor when set). Unit-tested. + - ✅ **7c** (editor GUI, on `main` — eye-checked & approved 2026-06-16): the UI canvas (`PanelKind::UiCanvas`, + **centre tab beside the Viewport** — it needs the large central area), in a scroll area — + open/new a `UiPanel` document; widget-tree panel (selectable, using 7a), Add ▾ palette + (Leaf/Row/Column/Grid/Anchor), Remove, scaled canvas preview (egui painter), and a **type-aware + property panel**: id/text, kind-specific (Leaf intrinsic; Stack direction/gap/main-align; Grid + cols/rows/gap), visual (background/foreground/font size/**font asset picker**), and a Layout + section (sizing, align x/y, padding, margin, **anchor preset**) so widgets are sized and + positioned. Save to a `ui/` asset; edits route through `SetUiPanelCmd` (undoable). Follow-ups: + engine-font-accurate preview via the real `UiOverlayPass`; change-kind; widget drag-reorder + (the 7a `move_subtree` primitive is ready); anchor offset/handles for free drag-positioning. + +### Test Criteria + +- A brand-new component (e.g. a `Timer { repeating: bool, duration: f32 }`) becomes fully editable in + the inspector **and** over RON/scripts with only `#[derive(Reflect, Serialize, Deserialize)]` + one + registration line — **no per-type editor code**. +- The generic inspector reproduces the old Transform/Mesh inspectors' behavior (positions, rotation + in degrees, scale, mesh shape) — verified by an automated round-trip plus a manual eye-check. +- An asset referenced by project-relative path resolves to the same `Handle` across save/load and + survives moving the project directory. +- Selecting a UI element in the editor and picking a font from the asset browser updates the rendered + text. +- Manual: right-click the hierarchy to add/duplicate/delete; add a component from the inspector; pick + an asset for a `Handle` field. + +### Editor-UX follow-ups (deferred from piece 6c — maintainer feedback 2026-06-16) + +Piece 6c shipped a *functional* asset browser, but the maintainer flagged UX +improvements to do **properly later** (not blockers; kept out of their stages to +avoid feature creep mid-stage). **Reconfirmed + expanded 2026-06-17** (during +Stage 10): do this batch **after Stage 10 pieces 4 (terminal panel) & 5 (error +console)**. Each is GUI → `dev` + eye-check. + +- **Unity-style asset/file explorer in the Project panel.** Today the typed + folders (`fonts/ textures/ …`) are fixed, so importing means creating/using a + folder with that exact name. Wanted: a real file explorer — create/rename/move + folders, rename/delete assets, **drag files in from the OS** to import, context + menus, breadcrumb navigation. The `AssetDatabase` already tracks arbitrary + relative paths, so this is a UI/interaction layer over it (re-classify by + folder still applies; loose files fall back to extension). +- **Proper file/path opener for New/Open Project.** Today both take a raw typed + path, which the maintainer confirms "is very hard to use". Wanted: a native + file/folder picker (a `rfd`-style dialog, working on Wayland + X11) and/or a + better-designed in-editor chooser, instead of a free-typed path field. +- **"New Script" button on the `Script` component inspector** (Stage 10 + follow-up). Create a new `.rhai` from a template into `assets/scripts/` and + auto-assign it to the component, without leaving the editor — today scripts + must be authored as files outside the editor. The `.rhai` loader + + `AssetKind::Script` already exist; this writes a template file + registers it. +- **Open a script in an editor** (Stage 10 follow-up). Double-click / button to + open a `.rhai` in an in-editor text view or launch `$EDITOR` / a configured + external editor; edits flow back through the existing live reload. + +--- + +## Stage 8.7 — Editor Play Mode + +**Status: ✅ Complete on `main`** (all three pieces; piece 3 GUI eye-checked & approved 2026-06-16). +Inserted before Stage 9 because Stage 9 already assumes the editor can **play/pause/step** the +simulation to test physics, yet no piece defined that loop. Decided with the maintainer 2026-06-16: +a **hybrid** model — in-editor play as the primary loop now, a standalone launch later. +Docs: [[Play-mode](Play-mode)](Play-mode). + +### Piece breakdown +1. **Play-state model + snapshot/restore** — ✅ `main`. `PlayState {Editing,Playing,Paused}` + + `play_snapshot` on `EditorState` (`enter_play`/`toggle_pause`/`stop`); registry-aware + `oxide_engine::scene::SceneSnapshot` (captures reflected components + intrinsic `Tags`/ + `DisabledComponents`, not just `to_ron`'s hierarchy), restoring the scene bit-for-bit. +2. **Schedule driver primitives** — ✅ `main`. `App::step()` (one fixed tick, accumulator bypassed) + and `oxide_editor::play::tick_for` (the testable Playing→Frame / Paused+Step→FixedStep / else Idle + decision). +3. **Toolbar + viewport tint + runner** — ✅ `main`. Play/Pause/Step/Stop toolbar (gated by state, + badge; Resume routes through `play_or_resume`), Ctrl+P / Ctrl+. shortcuts, green/amber viewport + border; host runner owns the play `App`, swaps the editor scene in/out per tick (`state.scene` + stays the source of truth), undo cleared on Play/Stop. + +**Goal:** Drive the engine's update [`Schedule`](#stage-5--engine-core-framework) from the editor so +the open scene can be run, paused, single-stepped, and stopped **in the viewport**, with edits made +during play safely reverted on stop. The same fixed-timestep tick a shipped game uses is what the +editor drives, so play behaves like the real runtime. + +### Deliverables +- **Play-mode toolbar** (Play / Pause / Step / Stop) in the editor's top bar, with matching + shortcuts; a clear **"PLAYING" viewport tint / indicator** so edit vs play state is never ambiguous. +- **Play-state model** on `EditorState` (`Editing` / `Playing` / `Paused`); the host runner ticks the + `Schedule` (incl. the fixed-timestep slot) only while `Playing`, and advances exactly one fixed tick + per **Step** while `Paused`. +- **Snapshot / restore.** On **Play**, snapshot the scene (reuse the RON scene serialization); on + **Stop**, restore it, so play-mode mutations (physics moving bodies, scripts spawning entities) + never corrupt the authored scene — avoiding Unity's classic "edited in play mode, lost it" footgun. +- **Live inspect during play.** The reflection inspector and gizmos keep working while `Paused` (and + `Playing`), so a field can be tweaked and the result observed live — the payoff of the Stage-8.5 + reflection work. (rhai scripts hot-reload with no compile step, so iteration is instant.) +- **Deferred to Stage 16 (export):** a **"Launch standalone"** button that runs the *real* exported + runtime in a separate window/process via the export builder — the truest-to-ship check, reusing the + Stage-16 packer rather than the in-editor loop. + +### Test Criteria +- Press Play → the scene's update systems run and the viewport animates; Pause halts ticking; Step + advances exactly one fixed tick; Stop returns the scene **bit-for-bit** to its pre-play state + (automated: snapshot, mutate via a tick, stop, assert the scene RON matches the snapshot). +- Entering/leaving play mode does not leak entities, handles, or undo-stack entries into edit mode. +- Manual: play a scene, pause, edit a reflected field, observe the change; stop and confirm the scene + reverts. + +--- + +## Stage 9 — Physics Integration (comprehensive) +**Goal:** A comprehensive rigid-body physics system — collision, queries, constraints, and a +character controller — built as a **module** (`oxide-physics`) on `rapier3d`, not a minimal wrapper. + +### Deliverables +- Integrate `rapier3d` as a feature-gated module registered through the Stage-5 app builder +- `RigidBody` component: static, kinematic, dynamic (mass, damping, gravity scale, CCD) +- `Collider` component: box, sphere, capsule, cylinder, convex hull, trimesh; friction/restitution, + **collision groups/masks driven by the Stage-5 `LayerMask`**, and **sensor (trigger) colliders** + that report overlap without resolving (filtered by layer — e.g. a trigger that only fires for the + Player or NPC layer) +- Forces & control: apply force/impulse/torque, set velocities, sleep/wake +- **Joints/constraints**: fixed, revolute, prismatic, spherical +- **Scene queries**: raycast, shape-cast, point/overlap queries against the physics world, with + `LayerMask` filtering +- **Kinematic character controller** (capsule): move-and-slide, step offset, slope limit, grounded + state — reused later by the Stage-15 prototyping kit's character controller +- Collision/trigger **events** surfaced to game code (enter/stay/exit) +- Physics step synchronized with game loop (**fixed timestep** with interpolation to render, using + the Stage-5 fixed-timestep schedule slot) +- `examples/physics_stack` (boxes fall and collide) and `examples/character_capsule` + (walk/jump a capsule over terrain) +- **Editor:** collider shape gizmos in viewport; RigidBody/Collider inspector panels; play/pause/step + physics simulation; raycast debug visualization — all contributed via the Stage-6 extension API + +### Test Criteria +- A dropped sphere lands on a static plane and comes to rest +- Stacked boxes maintain stable contact without jitter +- Kinematic bodies move without being affected by forces +- Raycast/shape-cast return correct hits; sensor colliders fire enter/exit without resolving contact; + layer masks correctly include/exclude bodies from collisions, triggers, and queries +- A joint (e.g. revolute) constrains motion to its expected degrees of freedom +- Character controller climbs steps below the offset, is blocked by walls, and reports grounded +- Physics and render transforms stay in sync (no visual lag/offset) under fixed-timestep interpolation + +### Piece breakdown (status) +The `oxide-physics` crate is a new workspace member; the ECS is the source of truth and the rapier +world is a transient resource rebuilt from components (so play-mode snapshot/restore works for free). +Pure-logic/headless pieces go to `main`; editor gizmos/visual behaviour stop at `dev` for eye-check. + +| # | Piece | Where | Status | +|---|-------|-------|--------| +| 1 | Component data model + module wiring: `RigidBody`/`RigidBodyKind`, `Collider`/`ColliderShape`, `PhysicsModule`, `PhysicsSettings` (gravity) | `oxide-physics` (new crate) | ✅ on `main` | +| 2 | Rapier-backed simulation: build the world from components, step on `FixedUpdate`, write transforms back; by-entity forces/velocities/sleep | `oxide-physics` | ✅ on `main` | +| 3 | Collision groups/masks via `LayerMask`, sensors, collision/trigger events (enter/stay/exit) | `oxide-physics` | ✅ on `main` | +| 4 | Scene queries: raycast, shape-cast, point/overlap with `LayerMask` filtering | `oxide-physics` | ✅ on `main` | +| 5 | Joints/constraints: fixed, revolute, prismatic, spherical (programmatic API; component authoring needs serializable entity refs — backlog) | `oxide-physics` | ✅ on `main` | +| 6 | Kinematic character controller (capsule): move-and-slide, step offset, slope limit, grounded | `oxide-physics` | ✅ on `main` | +| 7 | `examples/physics_stack` + `examples/character_capsule` (headless console demos) | `examples` | ✅ on `main` | +| 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) | `oxide-editor` | ✅ on `main` | +| 8b | Editor viewport: collider shape wireframe gizmos (box/sphere/capsule/cylinder), green=solid / amber=sensor, View ▸ Show Colliders toggle; matches the sim (ignores `Transform::scale`) | `oxide-editor` | ✅ on `main` | +| 8c | Editor viewport: raycast debug viz — `PhysicsWorld::sync_to_scene` (pure-logic) + a View ▸ Raycast Probe toggle: click freezes a camera→cursor ray into the world (orbit to view it as a 3D line) + hit point/normal | `oxide-physics` + `oxide-editor` | ✅ on `main` | + +Standalone "Launch" stays deferred to Stage 16. Convex-hull/trimesh colliders (need mesh data) land +within piece 2/3 or as a follow-up. + +--- + +## Stage 10 — Scripting, Live Reload & Editor Terminal +**Goal:** Game logic lives in watched scripts that hot-reload while the editor runs, an integrated +terminal hosts tools and AI agents, and every component type is editable from both the editor and +scripts/code through the Stage-5 reflection registry. + +### Deliverables +- **Scripting layer** (`rhai` preferred — embeddable, sandboxed, Rust-friendly): a `Script` + component attaching a script file to an entity; lifecycle hooks (`init`, `update(dt)`, events). + Scripts can also register lightweight **script modules** through the Stage-5 module API. +- **Component reflection / dual-editability**: scripts read/write engine component types through the + Stage-5 reflection registry, so the **same** fields are editable from the editor inspector, from + scripts, and from external tools/AI agents — backed by the serde/RON representation. Scripts can + spawn entities and add/edit components. +- **File watching + live reload**: extend the Stage-6 watcher to recompile/reload changed scripts + into the running editor with no restart, preserving scene state where possible. This is what lets + *any* external editor or AI agent edit game code and have it take effect live. +- **Integrated editor terminal panel**: a real shell/terminal inside the editor able to run commands + and host long-running processes — including **AI agents** that edit the watched scripts, whose + edits flow back through live reload. +- Safe error surfacing: a script error pauses that script and reports to the editor console/terminal + without crashing the editor. +- `examples/script_spin`: a script rotates an entity each frame; editing the script live changes the + spin without restarting +- **Editor:** terminal panel; script console (errors/logs/`print`); per-entity script attach/detach; + generic reflection-driven inspector for component fields + +### Test Criteria +- Editing a watched script changes running behavior without an editor restart +- A script reads and writes a component (e.g. `Transform`) and the editor inspector reflects the + same value (and vice-versa) — proving dual-editability over one representation +- A scripted entity spawn/despawn and component add/edit round-trips through RON serialization +- A script runtime error is reported and isolated; the editor stays alive +- A command run in the editor terminal executes and streams output back to the panel +- Manual: run an AI agent / external editor against the watched script dir and confirm live updates + +--- + +## Stage 11 — Animation System +**Goal:** Skeletal animation plays on a skinned mesh. + +### Deliverables +- `Skeleton` type: joint hierarchy with bind pose +- GLTF skinned mesh and animation clip loading (via the Stage-5 asset server) +- `AnimationPlayer` component: play, pause, seek, loop +- Animation blending (linear blend between two clips) +- `examples/anim_character`: load a character GLTF and play walk/idle clips +- **Editor:** animation timeline panel; clip browser; playback controls; blend weight sliders + +### Test Criteria +- Idle and walk clips play without visual artifacts +- Blend between clips produces smooth interpolation +- Seeking to frame 0 and frame N is deterministic +- Manual: visually verify no mesh distortion at extreme joint angles + +--- + +## Stage 12 — Particle System +**Goal:** GPU-driven particle emitters with configurable behavior. + +### Deliverables +- `ParticleEmitter` component: spawn rate, lifetime, velocity, gravity, color-over-lifetime +- GPU-side simulation (compute shader) +- Billboard rendering pass (registered into the Stage-5 render pipeline) +- Emitter types: point, cone, sphere surface +- `examples/particles_fire`: a fire-like particle effect +- **Editor:** particle emitter inspector with live preview; curve editor for color/size over lifetime + +### Test Criteria +- 100,000 particles maintain 60 FPS on mid-tier GPU +- Lifetime and color curves produce correct visual output +- Emitter enable/disable works without particle artifacts +- Manual: fire effect looks plausible + +--- + +## Stage 13 — Shader System & Advanced Graphics +**Goal:** Custom shaders, post-processing, and advanced rendering effects — the realistic end of the +*scalable fidelity* spectrum, all composed as passes in the Stage-5 render pipeline. + +### Deliverables +- Hot-reloadable WGSL shader pipeline (shaders watched via the Stage-6 file watcher) +- **Post-process stack** built as composable render passes: bloom, SSAO, tone mapping, FXAA, plus a + stylization slot (e.g. a **VCR/CRT-style filter**) — each pass opt-in, so a project ships only the + effects it enables +- Shadow mapping (directional light, single cascade) +- Skybox rendering (HDR cubemap) +- `examples/advanced_scene`: showcase scene with shadows, SSAO, bloom; `examples/stylized_scene`: + the same scene with a flat/low-poly look and a VCR post filter (demonstrating fidelity scaling) +- **Editor:** shader asset editor with hot-reload button; post-process stack configuration panel + (enable/reorder passes) + +### Test Criteria +- Shader hot-reload works without GPU crash or validation errors +- Shadows appear on correct geometry, no peter-panning +- SSAO adds visible depth cues on curved surfaces +- Enabling/disabling individual post passes changes only that effect; a project with no post passes + pays no post-process cost +- Manual: realistic scene looks polished; stylized scene shows the low-poly + VCR look + +--- + +## Stage 14 — Standard Audio System +**Goal:** A conventional, low-overhead audio system every project can use by default — the simple +audio model, with the ray-traced model available later as an opt-in module (Stage 17). + +### Deliverables +- Audio backend/device + mixer (`kira` or `rodio`): load/decode clips (WAV/OGG via the asset server), + play/stop/pause, volume, pitch, looping, per-bus mixing (master/music/sfx) +- `AudioSource` / `AudioListener` components designed to be shared with Stage 17 (same component + types, swappable backend) — **standard spatialization**: distance attenuation (inverse-square) + + stereo panning from listener-relative position, optional low-pass with distance +- One-shot and streaming playback; music crossfade +- Per-source **audio-model selector** (standard now; "ray-traced" becomes selectable once the + Stage-17 module is enabled) — both share components and the listener +- `examples/audio_basic`: positional sound that pans and attenuates as the listener moves +- **Editor:** audio source inspector (clip, volume, range, loop, bus); per-source model selector; + range-sphere gizmo + +### Test Criteria +- Clips play/stop/loop with correct volume and pitch; buses mix independently +- Panning and attenuation track listener-relative position (inverse-square within 5% error) +- Selecting a non-standard audio model on a source requires no component-type change (forward-compat + with Stage 17) +- Manual: positional audio feels correct moving around a source + +--- + +## Stage 15 — Engine Content & Prototyping Kit +**Goal:** Ship the batteries — built-in primitives, controllers, shaders, and example scenes — so +projects start building immediately instead of from an empty scene. The general-purpose capstone +before export. + +### Deliverables +- **Prototyping primitives**: ready-to-use cube/sphere/capsule/plane/cylinder meshes and a grid/ + checker "prototype" material, placeable from the editor (greybox blockout workflow) +- **Built-in character controller** asset: a drop-in first/third-person controller built on the + Stage-9 capsule controller + Stage-7 named actions (move/look/jump/crouch/sprint), remappable +- **Built-in shader library**: curated, documented WGSL shaders/materials (unlit, PBR, prototype + grid, simple sky) usable as-is or as templates for Stage-13's shader editor +- **Example games/scenes**: a small set of runnable example projects exercising the engine end to + end (e.g. a walk-around prototype level, a physics playground) +- **Editor:** an "Add ▸" content menu/asset browser exposing the primitives, controller, materials, + and example scenes +- Everything here is built only from already-shipped engine systems (no new core subsystems) + +### Test Criteria +- Each prototyping primitive spawns and renders correctly from the editor +- The built-in character controller walks/looks/jumps on a prototype level with remappable actions +- Built-in shaders compile and render without validation errors +- Example projects build and run via `cargo run` and from the editor +- Manual: blockout a small level using only built-in content and play it with the controller + +--- + +## Stage 16 — Windows Support & Game Export +**Goal:** A Windows build of the editor/engine, and one-click export of a project to standalone +Linux **and** Windows game binaries. **This completes Phase 1: a general-purpose engine that builds +and ships real games.** + +### Deliverables +- **Windows build** of `oxide-engine` and `oxide-editor` (verified on Windows; `wgpu` DX12/Vulkan, + `winit` Win32) alongside the existing Linux Wayland/Xorg build +- **Game runtime/export pipeline**: package a project's scenes, scripts, and assets into a + standalone runnable game that links the engine runtime (without the editor), compiling in **only + the modules the project enables** (validating the Stage-5 feature-gating) +- **Cross-target export**: from the editor (on Linux) export to both a Linux binary and a Windows + binary (cross-compile, e.g. `x86_64-pc-windows-gnu`), bundling assets next to the executable +- Export presets (target platform, asset packing, build profile) and a CLI equivalent for CI +- Packaging: keep `install.sh` for the editor; add export output layout docs for both platforms +- `examples/`: export one of the Stage-15 example projects to Linux and Windows binaries +- **Editor:** "Export Project" dialog (choose Linux/Windows targets, output dir, profile) + +### Test Criteria +- The editor builds and runs on Windows (Wayland/Xorg Linux build remains unaffected) +- A project exports to a Linux binary that runs standalone (no editor, no toolchain) with its assets +- The same project cross-exports to a Windows binary that runs on Windows +- Exported binaries contain no editor-only code paths and none of the modules the project didn't use +- Manual: run an exported Linux build and an exported Windows build of an example project + +--- + +# Phase 2 — Built-in Modules + +Each Phase-2 stage is a **self-contained, feature-gated module** built on Phase-1 systems and wired in +through the Stage-5 module API and Stage-6 editor extension API. Every module ships documentation for +both *using* it and *authoring* one like it, and each automatically works with export (Stage 16) and +the settings framework (Stage 6). The order below is a default; modules are largely independent and +can be reprioritized to match the needs of the first real game built in Oxide. + +## Stage 17 — Ray-Traced Spatial Audio (module) +**Goal:** Sound propagation that simulates real wave behavior from scene geometry — the advanced +audio model, built on the Stage-14 components so projects opt in per source. + +### Deliverables +- Reuses `AudioSource` / `AudioListener` from Stage 14 (ray-traced model selected per source) +- Sound ray casting: shoot rays from source, accumulate energy at listener (built on the engine's + own ray casts / `LayerMask` filtering) +- Occlusion: blocked rays reduce volume +- Reflection: rays bounce off surfaces and arrive with delay (reverb model) +- Distance attenuation using inverse square law +- `examples/sound_cave`: walk around a cave with echoing footstep sounds +- **Editor:** audio source visualizer (range sphere, ray debug lines); reverb parameter inspector; + the module's settings page + +### Test Criteria +- Sound behind a wall is measurably quieter than line-of-sight +- Moving away from source follows inverse square law within 5% error +- Reverb tail length correlates with room size +- Switching a source between standard and ray-traced models requires no component-type change +- Manual: walk through the cave example and confirm intuitive sound behavior + +--- + +## Stage 18 — Developer Console & Cheats (module) +**Goal:** A drop-in developer console any game can enable for commands, cheats, and debugging help — +trivially added to a project and removed from release builds. + +### Deliverables +- In-game console overlay (built on the Stage-8 UI) with command input, history, autocomplete, and + log output; toggled by a Stage-7 action (default backtick) +- **Command registry**: register named commands with typed arguments from engine, game code, scripts, + or other modules; built-in commands (spawn, teleport, set var, toggle layer, time scale, etc.) +- **Cheats / dev variables (cvars)**: registerable toggles and values (godmode, noclip, give, fly) + with a guard so they can be compiled out or locked in release/export builds +- Bind console commands to keys via the Stage-7 input system for quick dev shortcuts +- `examples/console_demo`: a scene with registered cheats and commands +- **Editor:** command browser/inspector; console mirrored in the editor; the module's settings page + (enable in build, default key, release lockout) + +### Test Criteria +- Registered commands execute with correct typed-argument parsing; unknown commands report cleanly +- Cheats/cvars change game state live and can be disabled/compiled out for release +- The console consumes input over the game via the Stage-8 routing model +- Console state and command registry are unaffected by hot-reload of game scripts +- Manual: enable the module in a game, open the console, run commands and toggle a cheat + +--- + +## Stage 19 — Procedural Toolkit (module) +**Goal:** Composable procedural *building blocks* — noise and a modifier/operation stack the user +combines — not a fixed world generator. Consumed by the terrain module (Stage 20) and usable +standalone. **Build tools, not the game.** + +### Deliverables +- Noise library: Perlin, Simplex, Worley, fractal octaves, domain warping (deterministic, seeded) +- **Modifier/operation stack**: chainable operations over heightfields/value grids (add, multiply, + terrace, erode, mask, remap, blend) authored as a data-driven, serializable graph/stack +- Seeded RNG utilities and distribution helpers for scatter/placement +- Simple L-system helper for vegetation/structure layout +- Region/`Biome` helper: blend by temperature/humidity/height maps (a tool, not a built-in world) +- `examples/procgen_playground`: build a noise + modifier stack and preview the result +- **Editor:** noise preview panel (2D heatmap); modifier-stack editor with live re-evaluation; seed + controls; the module's settings page + +### Test Criteria +- Same seed + same stack always produces identical output (determinism) +- Each modifier transforms its input correctly and composes in a chain +- A modifier stack round-trips through RON unchanged (dual-editable) +- Benchmark: evaluating a representative stack over a 1024² grid completes within target time +- Manual: build and preview a multi-stage noise stack in the editor + +--- + +## Stage 20 — Terrain System (module) +**Goal:** A comprehensive terrain module: generate terrain (via the Stage-19 toolkit), sculpt and +paint it with editor brushes, and scatter vegetation/objects onto it. Designed to stream later under +Stage 21. + +### Deliverables +- **Terrain representation**: chunked heightmap terrain with a meshing pass (normals, UVs) and + collider generation (heightfield collider via Stage 9); LOD-ready chunk layout +- **Generators**: drive terrain from the Stage-19 noise + modifier stack, from basic Perlin/Simplex + up to layered erosion/domain-warp stacks and biome-driven generation +- **Sculpt brush tools** (editor): raise/lower, smooth, flatten, noise — adjustable radius, strength, + falloff; live edits, undoable through the Stage-6 command stack +- **Texture/splat painting** (editor): paint multiple terrain material layers (e.g. grass/rock/sand) + with a splat map and per-layer tiling; brush-based painting with falloff +- **Foliage & object scatter** (editor): paint/spray grass, trees, rocks, or arbitrary prefabs with + density/scale/rotation jitter and slope/height masks; instanced rendering for dense foliage, + entity placement for larger objects +- **Dual-editable & serializable**: terrain data (heightmap, splat layers, scatter sets) round-trips + through RON so it is editable from editor and scripts/AI agents +- `examples/terrain_playground`: generate a terrain, sculpt/paint it, scatter grass and trees +- **Editor:** terrain tool panel (generate, sculpt, paint, scatter sub-tools); brush settings; + layer/material list; foliage/prefab palette; the module's settings page + +### Test Criteria +- A generated terrain produces correct mesh normals and a matching heightfield collider +- The same seed/generator stack produces identical terrain (determinism) +- Sculpt and paint brush edits modify the heightmap/splat map correctly and are undoable +- Scatter respects density and slope/height masks; instanced foliage renders without per-instance + entity overhead +- Terrain + splat + scatter data round-trips through serialization unchanged +- Manual: sculpt, paint layers, and scatter grass/trees on a terrain and confirm it collides correctly + +--- + +## Stage 21 — Open World Support (module) +**Goal:** Streaming, chunked worlds that can exceed memory limits. + +### Deliverables +- World chunk system: load/unload chunks based on camera position (built on the Stage-5 asset server) +- Async asset streaming (chunks load on background threads) +- Level of Detail (LOD) system for meshes and terrain +- Entity streaming: entities activate/deactivate with their chunk +- `examples/open_world`: a large world that streams seamlessly as you move +- **Editor:** world chunk map overlay; streaming debug view (loaded/unloaded chunks); LOD visualizer; + the module's settings page + +### Test Criteria +- No frame drops during chunk load/unload transitions +- Memory usage stays bounded regardless of world size +- Entities in unloaded chunks do not tick (verified via counters) +- Manual: drive across chunk boundaries and confirm invisible seams + +--- + +## Stage 22 — Pathfinding & NPC AI (module) +**Goal:** Built-in navigation and reusable NPC behavior building blocks, so games get agents that +move and decide without writing it all from scratch. + +### Deliverables +- **NavMesh**: bake a navigation mesh from scene/terrain geometry (with `LayerMask`-driven + walkable/obstacle filtering); runtime queries (path find, nearest point, path smoothing) +- **Agent component**: follow a path with steering, avoidance, speed/acceleration, and grounded + movement via the Stage-9 character controller +- **NPC AI building blocks**: a behavior-tree and/or state-machine system, perception helpers + (vision cones / hearing using scene queries and layers), and steering behaviors (seek/flee/wander/ + patrol) — composable, not a fixed AI +- Dual-editable, serializable behavior/agent definitions +- `examples/npc_patrol`: agents navigate, patrol waypoints, chase the player, and avoid obstacles +- **Editor:** navmesh bake/visualize panel; agent inspector; behavior-tree/state-machine editor; the + module's settings page + +### Test Criteria +- A baked navmesh produces correct paths around obstacles; queries return valid points +- Agents follow paths, avoid each other, and stop at goals without jitter +- A behavior tree/state machine transitions correctly and is serializable (dual-editable) +- Perception correctly gates on layers and line-of-sight +- Manual: watch NPCs patrol, detect, chase, and give up + +--- + +## Stage 23 — Water System (module) +**Goal:** Built-in water — rendering and gameplay mechanics — that a project can drop in. + +### Deliverables +- **Water rendering**: animated water surface (waves/normals), reflection/refraction, depth-based + color/fog, shoreline blending — composed as Stage-5 render passes and a Stage-13 shader material +- **Water bodies**: planar/ocean and bounded volumes (lakes/pools) with configurable height/level +- **Gameplay mechanics**: buoyancy/floating on Stage-9 rigid bodies, water-volume triggers + (enter/exit, submerged state), simple flow/current forces, and a swim mode for the Stage-15 + character controller +- Optional underwater post-process (tint, distortion, muffled audio hook for Stages 14/17) +- Dual-editable, serializable water-body definitions +- `examples/water_scene`: a floating object, a swimmable lake, and an ocean horizon +- **Editor:** water body inspector and placement gizmos; surface/material settings; the module's + settings page + +### Test Criteria +- Water renders with plausible reflection/refraction and correct depth blending at shorelines +- Rigid bodies float and settle at the correct waterline; flow forces push bodies as configured +- Water-volume triggers fire enter/exit and report submerged state correctly +- The character controller enters swim mode in water and exits on leaving +- Manual: drop objects in water, swim the character, and confirm it looks and behaves correctly + +--- + +## Backlog (additional modules & post-roadmap capabilities) + +### Editor UX / inspector (raised 2026-06-16, maintainer GUI review) +- **Editor visual overhaul** — overall styling, spacing, and panel layout need a + dedicated polish pass (egui theme, inspector/panel density). Tracked as a + standalone editor-styling piece, not folded into feature work. +- **Component gizmos / viewport handles** — components with spatial meaning need + editor-only viewport visualization: `DirectionalLight` direction arrow (+ cone + for future spot lights), camera frustum, collider/audio shapes. Pairs with the + multiple-light-types work below. +- **Multiple light types** — `DirectionalLight` is the only light today; add + point + spot (and the renderer support to gather light *entities*, which the + reflected `DirectionalLight`/`Camera` components are scaffolding toward). +- **Component multiplicity model (DECIDED 2026-06-16 — hybrid).** Archetypal ECS + (`hecs`) allows one component of a given type per entity. Chosen approach, + applied as each component type lands: + - **Local-offset field** for things that are naturally *single per node but + positioned* — a collider's `center`, a light/audio emitter offset — stored as + a `Vec3`/`Transform` field on the component (one node, no extra hierarchy). + This mirrors Unity (`BoxCollider.center`). + - **Child entities** for *genuine multiples* (several meshes on one logical + object): each is a real node with its own `Transform`, gizmo-movable and + shown in the viewport — what Unity/Bevy do for multi-mesh. The "Add … (as + child)" path stays; editor UX is polished so it doesn't feel like clutter. + - Explicitly **not** doing internal multi-instance lists + sub-object gizmos + (rejected as the biggest lift / least ECS-native). + Concretely: colliders (Stage 9) and audio sources (later) get offset fields; + multi-mesh stays child-based. + +- Networking (deterministic lockstep or rollback for simulation use) +- Multi-threading and job system (rayon or custom) +- **Dynamically loadable native plugins** (C-ABI/dylib) — currently modules are compile-time crates + or `rhai` scripts; revisit once the static module API is proven +- Save-format/asset versioning and migration (backward compatibility for shipped projects) +- VR/XR support +- Volumetric fog and clouds +- GPU-driven rendering (indirect draw, bindless resources) +- Terrain deformation at runtime +- Fluid simulation (beyond the Stage-23 water surface model) +- Vehicle physics module +- Destruction / fracture module +- **SDF (signed-distance-field) text rendering** for the Stage-8 UI — each + glyph rasterized once at one canonical size as an SDF, then scaled + arbitrarily in the shader. Decouples atlas memory from font-size + combinatorics (HiDPI, animated scale, many sizes) and is how Unity + TextMeshPro / Unreal Slate / Godot 4's font system all ship. Slots in + next to the piece-3 `ab_glyph` rasterizer without rewriting the shaper. + +--- + +## Testing Protocol (All Stages) + +1. **Unit tests** — every module has tests for core behavior and edge cases +2. **Integration tests** — the `tests/` crate runs end-to-end scenarios +3. **Example review** — each stage ships at least one runnable example; both user and Claude review it +4. **Benchmarks** — performance-sensitive systems have `criterion` benchmarks; regressions block stage completion +5. **Clippy + fmt** — zero warnings, consistent formatting required before stage sign-off +6. **Module authoring docs** — every module (Stage 9 on) ships both a usage guide and an authoring + guide under `docs/`; a stage is not done until its docs land diff --git a/Scene.md b/Scene.md new file mode 100644 index 0000000..e582ca6 --- /dev/null +++ b/Scene.md @@ -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 +``` + +`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 +let on = scene.is_enabled(moon); // Option +let local = scene.local_transform(moon); // Option +``` + +### Querying + +```rust +for &root in scene.roots() { /* … */ } +for &child in scene.children(planet) { /* … */ } +let parent = scene.parent(moon); // Option +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::(planet); // Option> +``` + +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. diff --git a/Scripting.md b/Scripting.md new file mode 100644 index 0000000..5d10c3b --- /dev/null +++ b/Scripting.md @@ -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` | 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`](Assets), not a live `Handle`, so it is +serializable and stable across runs; the inspector recognises the +`AssetRef` 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::("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` 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`, 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::("…")`). 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 diff --git a/Settings.md b/Settings.md new file mode 100644 index 0000000..1f0695d --- /dev/null +++ b/Settings.md @@ -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::("editor"); + +// Typed read/write. +settings.get_mut::("editor").unwrap().theme = "dark".into(); +assert_eq!(settings.get::("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::("editor"); +let saved = settings.export(); // BTreeMap + +let mut restored = Settings::new(); +restored.register::("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 diff --git a/UI.md b/UI.md new file mode 100644 index 0000000..7191284 --- /dev/null +++ b/UI.md @@ -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` fields: `background`, + `foreground`, `border` (color + width), `corner_radius`, `font`, and + `font_size`. `None` means *inherit*; `Some` means *override*. Every field + serializes via `skip_serializing_if = "Option::is_none"`, so an empty + visual style vanishes from RON entirely. +- **`Theme`** — `default: VisualStyle` plus `styles: BTreeMap`. The `BTreeMap` (not `HashMap`) gives deterministic RON + output, important for diff-friendly UI documents and reproducible test + snapshots. +- **`Widget`** gains two fields: `visual: VisualStyle` (per-instance + overrides) and `theme_style: Option` (opt-in name into the + theme's named map). + +The cascade — implemented by `Theme::resolve(style_ref, override_with)` and +exposed on the widget as `Widget::resolve_visual(&theme)`: + +1. Start with `theme.default`. +2. If the widget specifies `theme_style: Some(name)` and the theme has a + matching entry, merge it on top (a missing name is treated as "no + contribution", not an error). +3. Merge the widget's per-instance `visual` on top. + +Each merge is field-by-field via `VisualStyle::merged(self, override_with)`: +right-hand `Some` wins, otherwise the left-hand value is kept. The same +primitive will drive runtime state overlays in piece 5 (hover, focus, +press). + +`FontRef` carries `family`, `weight: FontWeight`, and `italic: bool`. The +descriptor stores **names**, not paths: portable across machines, and the +runtime (piece 3) is free to pick the platform's best match. `FontWeight` +exposes `opentype_value()` returning the OpenType 100–900 weight scale. + +`VisualStyle` also has a `font_asset: Option>` (Stage 8.5 piece +7): a reference to a **specific project font asset** under `assets/fonts/`, +chosen in the editor's UI canvas from the asset browser. When set it takes +precedence over the `font` descriptor — the renderer resolves the [`AssetRef`] +to a loaded face through the [asset database](Assets) (a default-registered +`FontLoader` makes `.ttf`/`.otf` loadable via the `AssetServer`). `None` falls +back to the descriptor / theme path. This is the engine's first `AssetRef` +field and the asset-picker's end-to-end target. + +[`AssetRef`]: ../engine/src/asset/database.rs + +### Quick example + +```rust +use oxide_engine::math::{Color, Vec2}; +use oxide_engine::ui::{Border, FontRef, Theme, VisualStyle, Widget}; + +let theme = Theme::new() + .with_default(VisualStyle { + foreground: Some(Color::BLACK), + background: Some(Color::WHITE), + font: Some(FontRef::regular("Inter")), + font_size: Some(14.0), + ..VisualStyle::EMPTY + }) + .with_style( + "button", + VisualStyle { + background: Some(Color::rgb(0.85, 0.85, 0.9)), + border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)), + corner_radius: Some(4.0), + ..VisualStyle::EMPTY + }, + ); + +let play = Widget::leaf(Vec2::new(80.0, 24.0)) + .with_id("play") + .with_theme_style("button") + .with_visual(VisualStyle { + background: Some(Color::rgb(0.2, 0.4, 0.8)), // primary-button accent + foreground: Some(Color::WHITE), + ..VisualStyle::EMPTY + }); + +let resolved = play.resolve_visual(&theme); +assert_eq!(resolved.foreground, Some(Color::WHITE)); // per-instance wins +assert_eq!(resolved.corner_radius, Some(4.0)); // inherited from "button" +assert_eq!(resolved.font, Some(FontRef::regular("Inter"))); // inherited from default +``` + +### RON dual-edit + +`Theme::to_ron` / `Theme::from_ron` round-trip themes through pretty-printed +RON, matching `Widget::to_ron` from piece 1. `BTreeMap`-ordered output keeps +named styles alphabetised so diffs are stable. Empty fields (`None` options, +empty maps, `FontWeight::Regular`, `italic: false`) skip serializing — the +default form of any of these structs is `()` in RON. + +## What's in piece 3 — text shaping & glyph atlas + +The text subsystem lives at `oxide_engine::ui::text` and splits into three +sub-modules that compose, but each is testable on its own: + +- **`font`** — owns `Font` (a thin wrapper around `ab_glyph::FontVec`), + `FontId`, and `FontStore`. `Font::rasterize(glyph, size_px)` returns a + `RasterizedGlyph` with an alpha mask + per-glyph bearings + advance. + `FontStore::insert_with_descriptor(FontRef, Font)` indexes a font under a + piece-2 `FontRef`, so a theme's `font: Some(FontRef::bold("Inter"))` + resolves to a `FontId` the shaper can use. +- **`atlas`** — `GlyphAtlas::new(width, height)` allocates a single R8 + (alpha-only) buffer; `get_or_rasterize(GlyphKey, &FontStore)` returns the + glyph's `AtlasEntry` (UV rect + size + bearing + advance), rasterizing + and packing on first miss and serving the cache forever after. The + packer is a **best-fit shelf packer** — simple, deterministic, and + near-optimal density for the typically-uniform glyph heights of one font + at one size. The `dirty()` flag tells the piece-4 render pass when the + texture needs re-upload. +- **`shape`** — `shape(text, style, ¶ms, &fonts)` turns a string into + a `ShapedText { lines, size }` of positioned `ShapedGlyph`s. Each glyph + carries a `GlyphKey` the renderer feeds back into the atlas, and a + `position` at the **baseline** (not the top-left). Algorithm: + greedy line-break at ASCII whitespace, multi-font runs supported via + `shape_runs(&[TextRun])`, alignment within `max_width` (Left / Center / + Right), DPI scaling via `ShapeParams::scale`. + +### The atlas is the cache + +`GlyphAtlas` keys entries by `(FontId, GlyphId, size_px rounded to nearest +integer)`. Every glyph is rasterized **exactly once** per (font, glyph, +size) triple — a HUD that repaints `"HP: 1234 / 1500"` every frame +rasterizes the ten ASCII characters one time at startup and then runs +purely on textured quads. The integration suite verifies this: +`shaped_hud_text_is_cached_after_one_frame` shapes a three-line HUD, +walks every glyph through the atlas twice, and asserts the atlas's +`dirty` flag stays false on the second pass — i.e., zero new +rasterizations. The library choice (ab_glyph vs fontdue) only affects +the one-time miss cost, not steady-state. + +### Quick example + +```no_run +use oxide_engine::math::Vec2; +use oxide_engine::ui::text::{ + shape, Font, FontStore, GlyphAtlas, ShapeParams, TextAlign, TextStyle, +}; + +let mut fonts = FontStore::new(); +let id = fonts.insert(Font::from_path("/usr/share/fonts/.../Inter-Regular.ttf").unwrap()); +let style = TextStyle { font: id, size_px: 14.0 }; +let params = ShapeParams { + max_width: Some(300.0), + align: TextAlign::Center, + line_height: 1.4, + scale: 1.0, +}; +let shaped = shape("Press F to pay respects", style, ¶ms, &fonts); + +let mut atlas = GlyphAtlas::new(1024, 1024); +for line in &shaped.lines { + for glyph in &line.glyphs { + // Render with the atlas's bearing offset; this is exactly the + // call piece 4's overlay pass will make per glyph per frame. + if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) { + let quad_top_left: Vec2 = glyph.position + entry.bearing; + let _quad_size: Vec2 = entry.size_px; + let _ = (quad_top_left, entry.uv_min, entry.uv_max); + } + } +} +``` + +### Limitations (deliberate, scoped to piece 3) + +- One glyph per `char` — no ligatures, no combining marks, no complex- + script shaping (Arabic, Devanagari, Thai). The data path is ready for + a future `rustybuzz`-shaped intermediate; the current shaper just + doesn't invoke one. +- No BiDi or RTL — text flows left-to-right. +- No hyphenation or character-level break inside an over-wide word. +- ASCII whitespace only (`\t` and `\r` are treated as spaces). +- No bold/italic synthesis — each face is a separately-loaded `Font`. + +### Font choice + +The engine doesn't bundle a font; piece-3 tests use whichever sans-serif +they find on `/usr/share/fonts/` (or `/System/Library/Fonts` on macOS) via +`common_system_font_paths()`, skipping with `eprintln!("SKIP: …")` when no +candidate is present. The default UI font shipped with examples is a +piece-7 decision. + +### Why ab_glyph + +`ab_glyph` is a TTF parser + rasterizer only. It does not do layout — +which is fine because the shaper above already owns that. With +`fontdue` we would have gotten line wrapping for free at the cost of +living inside a fixed layout model; with `ab_glyph` we own every line- +break, kerning, and alignment decision. That control buys us a clean +path to richer features later: rich-text markup, per-character +animation, in-canvas editor caret positioning, and **SDF font +rendering** — a future follow-up where each glyph is rasterized once +as a signed-distance field and the shader scales it to any size for +free. SDF is on the Stage-8 backlog in [PLAN.md](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` 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` per frame; each batch + pairs an MVP matrix with a `PaintedFrame`. For piece 4a the host builds + one batch with `UiBatch::screen_space(painted, target_size)` — an + orthographic projection from window pixels to NDC with y-down (origin at + the top-left). + +### Vertex format and shader + +One vertex format, one fragment path: + +```text +struct UiVertex { position: vec2, uv: vec2, color: vec4 } // 32 bytes +``` + +The shader (`engine/src/render/shaders/ui.wgsl`) discriminates "solid quad +vs. glyph quad" by a sentinel UV: `uv.x < 0.0` skips the atlas sample. So +a solid red rectangle and a glyph from "Inter" pass through identical +pipeline state and live in the same vertex buffer — no state changes per +primitive, no separate textures. Alpha-blending is on; UI never reads +depth (it overlays). + +### Atlas lifecycle + +Each frame's `run`: + +1. Walk every glyph in every batch, calling + `GlyphAtlas::get_or_rasterize(key, &fonts)` to ensure the entry is + cached. Misses rasterize once; hits do nothing. +2. If the atlas's `dirty` flag is set, re-upload the whole R8 buffer to + the GPU texture and clear the flag. Re-uploading the whole atlas (vs. + tracking dirty sub-rects) keeps the code simple; the buffer is small + (1 MB at 1024×1024) so this is fine. A dirty-region upload is a + straightforward follow-up if a profile says it's hot. +3. For each batch: serialize draw commands into vertices, write the MVP + uniform, set the viewport from `FrameContext::resolved_viewport()`, + and submit one draw call. + +### Test strategy + +The piece-4 tests live in three places: + +- `engine/src/ui/paint.rs` — 5 lib tests verify the CPU paint logic: a + solid widget emits one quad at its rect, a text widget emits one glyph + command per visible char at the same baseline, layered widgets draw + parent-before-child, etc. No GPU required. +- `engine/src/render/ui_pass.rs` — 4 headless GPU pixel-readback tests: + a 20×20 red quad shows red at its centre and clear-color outside; an + empty batch list is a no-op; two quads in one batch both render to + their respective rects; the vertex buffer grows when a batch exceeds + the initial 4096-vertex capacity. +- `tests/src/lib.rs` mod stage8 — 1 integration test runs the whole + pipeline: `Widget` → `layout` → `paint` → `UiOverlayPass::run` → + pixel-readback, then asserts the centred 48×48 red panel is red in the + middle and clear-color in the gutter. + +Both lib and integration GPU tests skip with `eprintln!("SKIP: ...")` if +no adapter is available, matching the Stage-4 pattern. + +### Wiring it into an app + +```rust,no_run +use oxide_engine::math::Color; +use oxide_engine::render::{RenderPipeline, UiBatch, UiOverlayPass}; +use oxide_engine::ui::{layout, paint, FontStore, Theme, Widget}; +# fn build_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) { +let mut pipeline = RenderPipeline::forward(device, format); +let ui_pass = UiOverlayPass::new(device, format); +pipeline.add_pass("ui", ui_pass); +# } +# fn each_frame( +# ui_pass: &mut UiOverlayPass, +# document: &Widget, +# theme: &Theme, +# fonts: &FontStore, +# viewport: oxide_engine::math::Rect, +# target_size: (u32, u32), +# ) { +let tree = layout(document, viewport, 1.0); +let painted = paint(document, &tree, theme, fonts, 1.0); +ui_pass.set_batches(vec![UiBatch::screen_space(painted, target_size)]); +// pipeline.render(&mut frame); — at next frame. +# } +``` + +## What's in piece 4b — world-space UI panels + +`oxide_engine::ui::UiPanel` is a pure-data holder: a `Widget` tree plus two +sizes — `pixel_size` (the resolution the UI is laid out at) and +`world_size` (the panel's physical dimensions in world units). It does +*not* own the panel's `Transform`; that lives on the entity that hosts +the panel (eventually a hecs component), so the same panel can be +duplicated across many entities with different placements. + +`UiBatch::world_space(painted, pixel_size, world_size, &panel_transform, +view_projection)` composes a single MVP that the existing piece-4a pass +uses unchanged: + +```text +mvp = view_projection + * panel_transform // world placement + * scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (y-flip) + * translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin +``` + +A pixel at `(0, 0)` in the painted frame lands at the panel's top-left +corner in world space; a pixel at `pixel_size` lands at the bottom-right. +Same pipeline, same shader, same atlas — only the MVP differs. + +`UiPanel::build_batch(theme, fonts, &panel_transform, view_projection)` +is the convenience that lays out + paints + builds the batch in one call. +Hosts that want finer control compose the same three steps by hand. + +### Overlay semantics + +World-space panels in piece 4b render as **overlays**: no depth test, no +depth write — they draw on top of whatever's in the colour target. That +keeps the implementation simple and matches the common "always-visible" +use case (player nameplates, mission markers, editor canvas previews). + +A future **depth-aware mode** (where a panel behind a wall is properly +hidden) is in PLAN.md's Stage-8 backlog and slots in by attaching the +depth target to a second pass of the same pipeline. + +### Tests + +- 4 lib tests on `UiPanel`: `build_batch` returns `None` on zero + `pixel_size`, succeeds on a valid panel, the panel round-trips through + RON for dual-edit, and the identity-MVP sanity check maps pixel + `(0, 0)` to world `(-world.x/2, +world.y/2)` (verifying the y-flip). +- 1 GPU pixel-readback lib test (`world_space_panel_renders_inside_its_projected_region`): + a 2 m × 2 m red panel at the origin under a 60° camera 3 m away, + asserts the framebuffer centre is red and corners stay clear. +- 1 integration test (`ui_panel_in_3d_renders_under_perspective_camera`): + exercises the full `UiPanel::build_batch` → `UiOverlayPass` path end + to end with a real perspective camera and a pixel-readback assertion. + +### Quick example + +```rust,no_run +use oxide_engine::math::{Color, Transform, Vec2, Vec3}; +use oxide_engine::render::{Camera, UiBatch, UiOverlayPass}; +use oxide_engine::ui::{FontStore, Theme, UiPanel, VisualStyle, Widget}; + +# fn each_frame(pass: &mut UiOverlayPass, panel: &UiPanel) { +let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0); +let view_transform = Transform::looking_at(Vec3::new(0.0, 1.5, 4.0), Vec3::ZERO, Vec3::Y); +let view_projection = camera.view_projection(16.0 / 9.0, &view_transform); + +// Where the panel sits in the world. Treat as if it were a Transform +// component on the entity hosting the panel. +let panel_transform = Transform::default(); + +let theme = Theme::new(); +let fonts = FontStore::new(); +let batch = panel + .build_batch(&theme, &fonts, &panel_transform, view_projection) + .expect("valid panel"); +pass.set_batches(vec![batch]); +// pipeline.render(&mut frame); — at next frame. +# } +``` + +## What's in piece 5 — input routing + +The UI must consume input *before* the game (PLAN.md): clicking a button +shouldn't also fire the game action bound to the same mouse button. +`oxide_engine::ui::routing` gives the host one object that does this +end-to-end: + +```rust,no_run +use oxide_engine::prelude::*; +use oxide_engine::ui::Router; + +# fn each_frame(router: &mut Router, tree: &UiLayoutTree, input: &InputState) { +let frame = router.process(tree, input); +if !frame.captured_mouse { + // game receives mouse this frame +} +if !frame.captured_keyboard { + // game receives keys this frame +} +for event in &frame.events { + // piece 6 will dispatch each event to the matching widget's callback +} +# } +``` + +### Hit-test + +`hit_test(&LayoutTree, point) -> Option<&LayoutNode>` walks the laid-out +nodes in **reverse order** — the same order as paint (parents-then- +children, earlier siblings before later ones), so the topmost-drawn +widget is the first one tested. Anonymous widgets +(`WidgetId::default()`) are skipped so a decorative container doesn't +block clicks reaching the button inside it. + +### State machine + +The `Router` persists three pieces of state across frames: + +- **hovered** — recomputed each frame from the cursor + hit-test. +- **focused** — set when the cursor presses over a widget; cleared when + the cursor presses outside any widget. Survives subsequent hover + changes so a focused text input keeps focus while the cursor moves. +- **pending presses** — per-button, the widget that received the + most-recent unreleased press. A press → release on the **same** + widget emits `Clicked`. Drag-off then release cancels the click. + +### Events + +`RouterFrame.events: Vec` collects everything that +happened: `Hovered` / `Unhovered`, `Pressed` / `Released` / `Clicked` +(per mouse button), `FocusGained` / `FocusLost`. Piece 6 will dispatch +each event to per-widget callbacks; piece 5 is purely the state machine +producing the event list. + +### Tests + +- 13 lib tests cover hit-test (topmost wins, anonymous skipped, + outside-root → None, padding gutter resolves to parent), hover/ + unhover/swap-on-move, press → focus, press + release on the same + widget → click, drag-off cancels click, press outside clears focus, + captured-flag transitions, and cursor-unset → no hover. +- 1 integration test exercises the full hover → press → release → + click → move → press-outside-loses-focus sequence end-to-end with a + synthetic `InputState`. + +All tests are pure-CPU; no GPU, no font, no window. + +### What's deliberately not in piece 5 + +- **Keyboard focus navigation** (Tab / arrow keys to move focus) — a + small follow-up on top of the existing focus state. +- **Per-widget callbacks** — piece 6. +- **World-space hit-test** — clicking through a 3D panel needs a + ray-cast and an inverse-MVP. A follow-up that slots in by adding a + `Router::hit_test_world(ray, &UiPanel, &Transform)` helper. + +## What's in piece 6 — events + data binding + +Piece 6 takes the **immediate-mode** stance (same as Bevy UI and egui): +no callback storage, no `Rc>` for state, no lifetime +gymnastics — the host reads the `RouterFrame` each frame and acts +directly. + +### Events: immediate-mode queries on `RouterFrame` + +The piece-5 `RouterFrame` already carries the event list. Piece 6 adds +typed query methods that game code calls directly: + +```rust,no_run +# fn each_frame(frame: oxide_engine::ui::RouterFrame) { +use oxide_engine::winit::event::MouseButton; +if frame.clicked_left("play") { + // start_game(); +} +if frame.clicked("save", MouseButton::Right) { + // open_save_menu(); +} +if frame.hovered_in("tooltip-target") { + // show_tooltip(); +} +if frame.focus_gained("volume_slider") { + // ... +} +# } +``` + +The seven query methods — `clicked`, `clicked_left`, `pressed`, +`released`, `hovered_in`, `hovered_out`, `focus_gained`, `focus_lost` +— each take a widget id and (where applicable) a `MouseButton`, and +return `bool`. They scan the frame's event list, so the cost is linear +in the number of events emitted that frame — typically a handful. + +### Data binding: `Widget::value: Option` + +Every widget can carry typed state — a checkbox's bool, a slider's +float, a text input's string — independent of its `kind`. The +`WidgetValue` enum has variants `Bool(bool)` / `Int(i64)` / +`Float(f64)` / `Text(String)`, plus `From`-impls for `bool`, `i32`, +`i64`, `f32`, `f64`, `&str`, and `String`. + +Per-widget access uses `Widget::value(&id)` and `Widget::set_value(&id, +v)` — both walk the subtree to find the widget by id: + +```rust,no_run +use oxide_engine::ui::{Widget, WidgetValue}; +# fn pull_then_push(root: &mut Widget, audio_volume: &mut f32) { +// Pull game state into the widget tree (typically at the start of frame). +root.set_value(&"volume".into(), *audio_volume); + +// ... user interacts, slider widget updates its own value ... + +// Push the widget tree's value back into game state (at end of frame). +if let Some(v) = root.value(&"volume".into()).and_then(|v| v.as_float()) { + *audio_volume = v as f32; +} +# } +``` + +For values that don't change between frames (e.g., a label's string), +no binding is needed — set it once. + +### Why immediate-mode + +The persistent-callback alternative (each widget owns a +`Box`) forces every callback to either: + +- own its game state via `Rc>` (verbose, costs every + read), or +- borrow game state for `'static` (impossible), or +- defer to a queue (the same shape as immediate-mode, but indirected). + +Immediate-mode skips all three: the widget tree is **data**, not a +network of callbacks. The host's main loop is the dispatcher; the +piece-6 queries are just convenient predicates over the event list. + +### What's deliberately not in piece 6 + +- **Typed bindings helper** (`Bindings` that registers per-field + getter/setter pairs and runs them automatically) — adds a `Box` + abstraction over what's currently two lines of host code. Will land + alongside piece-7's settings example if the boilerplate becomes + painful. +- **Per-widget keyboard event delivery** (text input handling, hotkey + registration) — needs a focused-widget event-routing pass on top of + the piece-5 focus state. Either piece-7 or a follow-up. + +### Tests + +- 6 lib tests on `WidgetValue` cover accessor matching, `From` + conversions for every primitive, and RON round-trip for each variant. +- 4 lib tests on `Widget`: `find_by_id` / `find_by_id_mut` walk the + subtree, `set_value` updates a descendant by id, `with_value` builder + works, the value round-trips through `Widget`'s own RON. +- 2 lib tests on `RouterFrame`: query methods return true for matching + events, false for non-matching, across every event variant. +- 1 integration test (`settings_widget_tree_round_trips_game_state_each_frame`): + pulls game state into a settings panel, simulates user interaction + + an Apply click, pushes the widget values back into game state, and + asserts the round-trip is exact. + +## What's in piece 8 — `examples/ui_hud` + +`examples/src/bin/ui_hud.rs` is the second runnable Stage-8 example and +the first to **composite the UI over a 3D scene**. It reuses the +`hello_mesh` scene (spinning cube + sphere + ground plane through the +Stage-4 `ForwardPass`) and draws a HUD on top with a screen-space +`UiOverlayPass`. + +### Compositing two passes on one surface + +The window runner clears the surface to the configured clear color +*before* `render`. Both the forward pass and the UI overlay then use +`LoadOp::Load` for their color attachment, so each draws over whatever +is already there: + +1. `pipeline.render(&mut frame)` runs the forward pass — 3D geometry + plus its own depth buffer (cleared each call). +2. `ui_pass.run(&mut frame)` runs the overlay — no depth, alpha + blending — so the HUD sits on top of the 3D image. + +The host owns the `UiOverlayPass` separately from the `RenderPipeline` +(rather than `add_pass`-ing it) because the overlay needs `set_batches` +mutated every frame and the pipeline consumes pass ownership. Both +passes share the same `FrameContext`, so the example builds the 3D +objects and the painted HUD, then calls the two `run`s back to back. + +### Corner anchoring + +Each HUD element is an anchor child of a full-screen anchor root. A +corner-pinned, fixed-size widget is expressed as a corner `Anchor` +constant plus offsets that define its box — e.g. a top-left chip is +`Anchor::TOP_LEFT.with_offsets((M, M), (M + W, M + H))`, and a centred +crosshair is `Anchor::between((0.5, 0.5), (0.5, 0.5)).with_offsets(...)`. +The crosshair's two bars are themselves anchor children spanning one +axis and pinned thin on the other. + +### Demonstrating the atlas cache + +The HP and Ammo values animate every frame (HP oscillates down then up; +Ammo counts down as if firing, reloading at 0). The digits change +constantly, but the glyph atlas only ever rasterizes each character +**once** — after the digits `0`–`9` and the static label text have been +seen, the atlas stops growing and every later frame is a pure cache hit +(no rasterize, no GPU re-upload). The example logs each atlas growth and +the moment it reaches steady state, via two accessors added to the pass: + +```rust +pass.atlas_glyph_count(); // distinct glyphs cached so far +pass.atlas_dirty(); // grew-this-run flag (false in steady state) +``` + +The `atlas_caches_glyphs_and_reaches_steady_state` GPU test in +`render::ui_pass` proves this property automatically: it draws the ten +digits one per frame (asserting the count grows by one each time), then +re-draws a cached digit and asserts the count holds and the dirty flag +stays clear. + +Run it: `cargo run -p oxide-examples --bin ui_hud` (Esc quits). + +## What's coming in the rest of Stage 8 +- **Piece 9 — Editor UI canvas.** A new editor panel for visually + authoring `Widget` / `UiPanel` documents: a drag-from widget palette, + a canvas showing the document at target size with drag-resize handles, + a property inspector for `LayoutStyle` / `VisualStyle` / `text` / + `value`, RON save/load round-tripping the same format the runtime + loads, and a live preview rendered through the actual `UiOverlayPass` + (not egui). Likely splits into 9a (canvas + palette + inspector) and + 9b (live preview + drag/resize handles). + +The piece-1 data structures already accommodate the editor canvas: a +document is just a `Widget` tree, the inspector edits the same reflected +style structs the runtime uses, and the preview reuses the exact paint + +overlay pipeline the game ships with. diff --git a/Windowing.md b/Windowing.md new file mode 100644 index 0000000..61a6d42 --- /dev/null +++ b/Windowing.md @@ -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`. diff --git a/Working-notes.md b/Working-notes.md new file mode 100644 index 0000000..3585764 --- /dev/null +++ b/Working-notes.md @@ -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 0–16):** everything needed to build *and export* any + game. Stage 16 (game export to Linux + Windows) is the milestone. +- **Phase 2 — built-in modules (Stages 17+):** optional, self-contained capabilities (ray-traced + audio, developer console, procedural toolkit, terrain, open world, pathfinding/AI, water), each a + feature-gated **module** built on Phase-1 systems. + +Simulation, open world, and procedural generation are **capabilities the engine supports through +modules**, not design drivers. The guiding idea is **build tools, not games**. + +### Core Feature Goals (Phase 1 — the general-purpose engine) +- Scene graph and entity management +- **Engine core framework**: a **module/plugin system** (compile-time feature-gated crates + a + runtime `Module` trait + `rhai` script modules), system scheduling, a **layers & tags** system + (`LayerMask` for physics/render/query filtering + gameplay tags), a central **asset server** with + handles, a **reflection/type registry**, and a **data-driven render pass pipeline** +- **Editor framework & project system**: top menu, dockable panels, undo/redo command stack, a + **module→editor extension API** (modules add panels/menus/tools/inspectors), a **settings/ + preferences framework** (engine + editor + per-module settings, enable/disable modules), and a + **project system** (create/open/save projects, file watching) +- **Comprehensive input mapping**: map any key to press/up/down, *and* named remappable actions + (e.g. a `Jump` action defaulting to `Space` that game code references by name while players rebind + the physical key in settings) +- **Comprehensive in-game UI system**: widgets, layout, theming, text, and input routing that ship + inside exported games (distinct from the editor's `egui`); UI documents are serializable and + dual-editable, authored in a visual editor canvas +- **Comprehensive** physics simulation (`rapier3d`: colliders, joints, scene queries, sensors, + layer-filtered collision/triggers, kinematic character controller — not a thin wrapper) +- **Scripting + live reload + in-editor terminal**: game scripts are watched and hot-reloaded; the + editor hosts a terminal that can run tools and AI agents which edit game code live +- Animation system · particle engine · shader support (simple → advanced, scalable fidelity) +- **Standard audio**: mixer/spatial system (ray-traced spatial sound is a Phase-2 module) +- **Dual-editable types**: every engine object/component type (`Transform`, `Script`, materials, + colliders, …) is editable from both the editor UI and from scripts/code via one reflected/ + serializable representation, so any editor or AI agent can author game code and data in real time +- Built-in content kit (prototyping primitives, shaders, character controller) for fast starts +- **Game export** to standalone Linux + Windows binaries (ships only the modules a project uses) +- **In-engine editor** (first-class; not an afterthought) + +### Built-in Modules (Phase 2 — optional, feature-gated) +- Ray-traced spatial audio (wave propagation, occlusion, reverb) +- Developer console & cheats (drop-in dev interface; compiled out of release) +- Procedural toolkit (noise + composable modifier stack — tools, not a fixed world generator) +- Terrain system (generate, sculpt, paint splat layers, scatter foliage/objects) +- Open world support (streaming, LOD, chunking) +- Pathfinding & NPC AI (navmesh, agents, behavior trees/state machines, perception) +- Water (rendering + buoyancy/swim/flow mechanics) + +Anyone can write a module; each ships docs for **both** using it **and** authoring one. + +### Platform & Targets +- **Linux is the primary platform, on both Wayland and Xorg (X11)** — keep both backends working at + every stage (`winit` `wayland` + `x11` features). +- **Windows support is added later**, once the engine is substantial; avoid Linux-only assumptions. +- **Game export targets both Linux and Windows** standalone binaries (editor runs on Linux and can + cross-export). See [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 \ No newline at end of file