From 3a072ada432182f64e3c8346228925277fa5a71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 8 Aug 2026 18:11:58 +0200 Subject: [PATCH] The engine documentation, moved out of the repository Twenty-six docs/ pages, the staged plan, the session handoff and the project rules, which were CLAUDE.md. Every internal link is rewritten: page-to-page links became wiki references, and links into the engine's own source became absolute URLs back into the repository, because a relative path only meant something while the document lived inside the tree. Three things were changed rather than copied. The instructions telling an agent to keep CLAUDE.md, PLAN.md and docs/ up to date now name this wiki -- left as they were, they would have recreated the files this move removes. The stale 'Local: /home/homer/Oxide' line is gone. And a rustdoc intra-doc link in Modules, which was never valid markdown and rendered as a broken link inside the repository too, is now plain code text. Co-Authored-By: Claude Opus 5 (1M context) --- Architecture.md | 157 ++++++ Assets.md | 246 +++++++++ Conventions.md | 90 ++++ Development.md | 111 ++++ Editor-extensions.md | 142 +++++ Editor-shell.md | 181 +++++++ File-watching.md | 154 ++++++ Getting-started.md | 113 ++++ Handoff.md | 257 +++++++++ Home.md | 64 ++- Input.md | 403 ++++++++++++++ Layers.md | 155 ++++++ Math.md | 353 ++++++++++++ Modules.md | 160 ++++++ Physics.md | 299 ++++++++++ Play-mode.md | 120 +++++ Prefabs.md | 83 +++ Projects.md | 85 +++ Reflection.md | 191 +++++++ Render-context.md | 105 ++++ Render-pipeline.md | 115 ++++ Rendering.md | 186 +++++++ Roadmap.md | 1230 ++++++++++++++++++++++++++++++++++++++++++ Scene.md | 232 ++++++++ Scripting.md | 332 ++++++++++++ Settings.md | 70 +++ UI.md | 860 +++++++++++++++++++++++++++++ Windowing.md | 144 +++++ Working-notes.md | 183 +++++++ 29 files changed, 6820 insertions(+), 1 deletion(-) create mode 100644 Architecture.md create mode 100644 Assets.md create mode 100644 Conventions.md create mode 100644 Development.md create mode 100644 Editor-extensions.md create mode 100644 Editor-shell.md create mode 100644 File-watching.md create mode 100644 Getting-started.md create mode 100644 Handoff.md create mode 100644 Input.md create mode 100644 Layers.md create mode 100644 Math.md create mode 100644 Modules.md create mode 100644 Physics.md create mode 100644 Play-mode.md create mode 100644 Prefabs.md create mode 100644 Projects.md create mode 100644 Reflection.md create mode 100644 Render-context.md create mode 100644 Render-pipeline.md create mode 100644 Rendering.md create mode 100644 Roadmap.md create mode 100644 Scene.md create mode 100644 Scripting.md create mode 100644 Settings.md create mode 100644 UI.md create mode 100644 Windowing.md create mode 100644 Working-notes.md 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