Import Oxide engine (Stages 0–10) under MIT license

Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+32
View File
@@ -0,0 +1,32 @@
# Rust / Cargo
/target/
Cargo.lock
# Editor and IDE
.vscode/
.idea/
*.iml
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment / secrets
.env
.env.*
# Build artifacts and packages
*.deb
*.rpm
*.tar.gz
*.zip
# Logs
*.log
# Personal scratch notes (tracked docs live in docs/ and are NOT ignored)
*.local.md
/scratch/
+160
View File
@@ -0,0 +1,160 @@
# Oxide Engine — Claude Code Context
## Project Overview
**Oxide** is a general-purpose 3D game engine written in Rust. It is built to make **any** 3D game,
scaling from stylized low-poly (e.g. with a VCR/CRT post filter) to realistic graphics, and to **ship
only what each game uses**. It ships with an **in-engine editor** (`oxide-editor`) developed alongside
the engine and gaining capabilities at each stage.
The work is split into two phases (see `PLAN.md`):
- **Phase 1 — general-purpose engine (Stages 016):** everything needed to build *and export* any
game. Stage 16 (game export to Linux + Windows) is the milestone.
- **Phase 2 — built-in modules (Stages 17+):** optional, self-contained capabilities (ray-traced
audio, developer console, procedural toolkit, terrain, open world, pathfinding/AI, water), each a
feature-gated **module** built on Phase-1 systems.
Simulation, open world, and procedural generation are **capabilities the engine supports through
modules**, not design drivers. The guiding idea is **build tools, not games**.
### Core Feature Goals (Phase 1 — the general-purpose engine)
- Scene graph and entity management
- **Engine core framework**: a **module/plugin system** (compile-time feature-gated crates + a
runtime `Module` trait + `rhai` script modules), system scheduling, a **layers & tags** system
(`LayerMask` for physics/render/query filtering + gameplay tags), a central **asset server** with
handles, a **reflection/type registry**, and a **data-driven render pass pipeline**
- **Editor framework & project system**: top menu, dockable panels, undo/redo command stack, a
**module→editor extension API** (modules add panels/menus/tools/inspectors), a **settings/
preferences framework** (engine + editor + per-module settings, enable/disable modules), and a
**project system** (create/open/save projects, file watching)
- **Comprehensive input mapping**: map any key to press/up/down, *and* named remappable actions
(e.g. a `Jump` action defaulting to `Space` that game code references by name while players rebind
the physical key in settings)
- **Comprehensive in-game UI system**: widgets, layout, theming, text, and input routing that ship
inside exported games (distinct from the editor's `egui`); UI documents are serializable and
dual-editable, authored in a visual editor canvas
- **Comprehensive** physics simulation (`rapier3d`: colliders, joints, scene queries, sensors,
layer-filtered collision/triggers, kinematic character controller — not a thin wrapper)
- **Scripting + live reload + in-editor terminal**: game scripts are watched and hot-reloaded; the
editor hosts a terminal that can run tools and AI agents which edit game code live
- Animation system · particle engine · shader support (simple → advanced, scalable fidelity)
- **Standard audio**: mixer/spatial system (ray-traced spatial sound is a Phase-2 module)
- **Dual-editable types**: every engine object/component type (`Transform`, `Script`, materials,
colliders, …) is editable from both the editor UI and from scripts/code via one reflected/
serializable representation, so any editor or AI agent can author game code and data in real time
- Built-in content kit (prototyping primitives, shaders, character controller) for fast starts
- **Game export** to standalone Linux + Windows binaries (ships only the modules a project uses)
- **In-engine editor** (first-class; not an afterthought)
### Built-in Modules (Phase 2 — optional, feature-gated)
- Ray-traced spatial audio (wave propagation, occlusion, reverb)
- Developer console & cheats (drop-in dev interface; compiled out of release)
- Procedural toolkit (noise + composable modifier stack — tools, not a fixed world generator)
- Terrain system (generate, sculpt, paint splat layers, scatter foliage/objects)
- Open world support (streaming, LOD, chunking)
- Pathfinding & NPC AI (navmesh, agents, behavior trees/state machines, perception)
- Water (rendering + buoyancy/swim/flow mechanics)
Anyone can write a module; each ships docs for **both** using it **and** authoring one.
### Platform & Targets
- **Linux is the primary platform, on both Wayland and Xorg (X11)** — keep both backends working at
every stage (`winit` `wayland` + `x11` features).
- **Windows support is added later**, once the engine is substantial; avoid Linux-only assumptions.
- **Game export targets both Linux and Windows** standalone binaries (editor runs on Linux and can
cross-export). See PLAN.md Stage 16.
## Development Philosophy
- Build in stages; each stage must be tested and stable before the next begins
- **Build tools, not games** — ship composable building blocks; genre-specific behavior lives in
game code or optional modules
- **Ship only what's used** — subsystems are feature-gated modules; an exported game compiles in only
the modules it registers
- **Scalable fidelity** — the data-driven render pass pipeline lets a project run anything from a flat
low-poly/stylized look to a full realistic stack, paying only for the passes it enables
- **Modules are the primary extension point** — a module registers engine logic *and* editor UI *and*
its own settings through one documented API; anyone (including AI agents) can write one
- Every system should be composable and independently usable
- Prefer correctness and clarity over premature optimization
- Keep public APIs minimal — internal complexity is fine, external surface should be clean
- No feature creep between stages; additions go into the backlog for later stages
- The editor (`oxide-editor`) grows with the engine — each stage adds editor support for new systems
through the Stage-6 editor framework (panels, menus, undo stack, settings pages)
## Documentation & File Maintenance
- **Always update** `CLAUDE.md`, `PLAN.md`, `README.md`, and `.gitignore` when project rules, goals, stage definitions, or project structure changes
- `PLAN.md` is the authoritative roadmap — keep it accurate and up-to-date
- `README.md` must reflect the current build/install instructions and feature list at all times. Keep it a **short overview** — detailed documentation belongs in `docs/`, not the README
- `.gitignore` must be updated whenever new tools, output formats, or file types are introduced that should not be tracked (e.g. new build targets, generated files, editor temp files)
- Do not let these files become stale after structural or process changes
### Full documentation in `docs/`
- The `docs/` directory holds the full documentation of the engine — both **usage** (how to call each system) and **inner workings** (how/why it works). The project is too large to fit this in `README.md`.
- **Write documentation as you work, not after.** A stage is not complete until its systems are documented under `docs/`.
- One topic per file; link between files rather than duplicating. `docs/README.md` is the documentation index — add new docs to it.
- When an API changes, update the affected doc and its code snippets **in the same change** so docs never drift from the code.
- Current docs: `architecture.md`, `conventions.md`, `getting-started.md`, `development.md`, and per-system references (e.g. `math.md`, `windowing.md`, `render-context.md`, `scene.md`).
## Packaging, Installation & Export
- The project must be compilable to a Linux installable package
- `install.sh` at the repo root builds in release mode and installs the editor binary plus assets to the system (`/usr/local` by default, overridable via `PREFIX`)
- Keep `install.sh` updated whenever new binaries or assets are added
- The installed binary name is `oxide-editor`
- The editor must run on Linux under **both Wayland and Xorg**
- A later stage adds a **Windows build** of the editor/engine and **game export** to standalone
Linux *and* Windows binaries (the exported game links the engine runtime without the editor) — see
PLAN.md Stage 16; keep packaging docs current when that lands
## Language & Tooling
- Language: Rust (stable toolchain)
- Build: Cargo workspace (`engine/`, `editor/`, `examples/`, `tests/`)
- Graphics: `wgpu` (portability across Vulkan, Metal, DX12)
- Physics: `rapier3d`
- Math: `glam`
- ECS: `hecs` (preferred lightweight approach)
- Editor UI: `egui` (integrated into `oxide-editor`)
- Scripting: `rhai` (preferred — embeddable, sandboxed); file watching via `notify` for live reload
- Audio: standard system via `kira`/`rodio`; ray-traced model built on the engine's own ray casts
- Windowing backends: `winit` with both `wayland` and `x11` enabled (Linux); Win32 later
## Repository
- Remote: `https://git.houmeres.sk/Houmeres/Oxide.git`
- Local: `/home/homer/Oxide`
## Git Workflow & Branching
Two long-lived branches: **`dev`** (integration) and **`main`** (stable). Full
detail in `docs/development.md`; the rules Claude follows:
- **Auto-commit and push to `dev`** every new piece of work that can be **fully
verified automatically** (it builds and its unit/integration/fuzz tests and
benchmarks pass). Do this as soon as the work is complete and green — no need
to ask first for these.
- **Manual-test gate before `main`.** If a change cannot be fully verified by
automated tests — anything involving the GUI, rendering, audio, input feel, or
otherwise needing a human to run the engine and observe it — push it to `dev`
only, then ask the maintainer to test it. Promote to `main` **only after the
maintainer explicitly approves** it works.
- **Fully-automated changes may go to both `dev` and `main` together**, because
the passing automated suite is the sign-off (e.g. pure-logic modules like the
math system).
- Decision rule: *"Can a test prove this works without a human looking at it?"*
Yes → eligible for `main`. No → stop at `dev` and request manual testing. When
in doubt, treat it as needing manual testing.
- Always run the local gate before committing:
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
- End AI-assisted commit messages with the Claude co-author trailer.
## Working with Claude
- Read `PLAN.md` for the full staged roadmap before starting any new stage
- Each stage has defined deliverables and test criteria — do not skip testing phases
- When implementing a system, prefer small focused modules over large monolithic files
- Breaking changes between stages are acceptable; backward compatibility is not a goal during early stages
- After completing work that changes project structure, goals, or process, update `CLAUDE.md`, `PLAN.md`, and `README.md` before considering the task done
+81
View File
@@ -0,0 +1,81 @@
[workspace]
resolver = "2"
members = [
"engine",
"engine-derive",
"physics",
"script",
"editor",
"examples",
"tests",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Jaroslav Beneš"]
license = "MIT"
rust-version = "1.75"
[workspace.dependencies]
# Math
glam = { version = "0.28", features = ["serde"] }
# ECS
hecs = "0.10"
# Windowing & graphics
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit
# backends explicitly enabled so neither can be dropped by a default-feature
# change. (On by default today; listing them makes the contract explicit —
# see PLAN.md "Platform & Target Strategy".)
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
wgpu = "29"
pollster = "0.4"
# Plain-old-data casting for GPU vertex/uniform buffers.
bytemuck = { version = "1", features = ["derive"] }
# glTF import (static meshes). `utils` enables the attribute reader helpers.
gltf = { version = "1.4", features = ["utils"] }
# TrueType / OpenType font parsing + glyph outline rasterization for the
# Stage-8 in-game UI text system. Chosen over `fontdue` for its minimal
# scope (parsing + rasterization only) — the engine writes its own atlas,
# layout, wrapping, and alignment on top, which keeps the door open for
# richer text features in later pieces (editor caret, rich markup, SDF).
ab_glyph = "0.2"
# Editor UI (egui — integrated into oxide-editor only)
egui = "0.34"
egui-wgpu = "0.34"
egui-winit = "0.34"
egui_dock = "0.19"
# Logging
log = "0.4"
env_logger = "0.11"
# Error handling
anyhow = "1"
thiserror = "2"
# Serialization
serde = { version = "1", features = ["derive"] }
ron = "0.8"
# Proc-macro toolkit for `oxide-engine-derive` (the `#[derive(Reflect)]` macro
# behind the reflection-driven editor inspector — Stage 8.5).
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
# Filesystem change events (Stage 6 file-watcher foundation; Stage 10 hot-reload).
notify = "8"
# Benchmarking
criterion = "0.5"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
+257
View File
@@ -0,0 +1,257 @@
# Oxide — Session Handoff
Drop this file into a new session and say "continue from HANDOFF.md". It
captures where we are, how we work, and exactly what to do next. Authoritative
roadmap is `PLAN.md`; project rules are `CLAUDE.md`; this is the "current
state + how to continue" snapshot.
_Last updated: 2026-06-17. **`main` and `dev` are aligned.** **Stage 9 — Physics
is ✅ COMPLETE on `main`.** **Stage 10 — Scripting, Live Reload & Editor Terminal:
core ✅ on `main`** (all eye-checked & approved 2026-06-17 in the `untitled`
project): the `oxide-script` crate (`Script` + `.rhai` loader + sandboxed
`ScriptEngine`), the `init`/`update(dt)` lifecycle via `ScriptHost`, the
`Vec3`/transform engine API, live reload (`examples/script_spin`), editor
integration (`Script` addable; play-loop shares the editor
`AssetServer`/`AssetDatabase` so a live `.rhai` edit updates a *playing* scene),
the **Console** (captures the `log` stream — script `print`/errors), a **command
terminal** (`$``sh -c`), and an **interactive PTY terminal** (`portable-pty` +
`vt100`; tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed
to the program — runs shells / TUIs / `claude`), and the **richer script API**
(scripts now `spawn_entity`/`despawn` and `add_component`/`set_component`/
`remove_component` on any entity via RON through the reflection registry —
closing the last PLAN round-trip criterion; pure-logic + headless tests → on
`main`). **What's left in Stage 10:** the maintainer's editor-UX batch —
Unity-style file explorer, native New/Open-Project dialog (typing the path by
hand is too hard), a **New Script** button on the `Script` inspector,
open-script-in-editor. See §5 to start. Parked, not blockers: editor-authored
joint **components** need a serializable entity-reference type._
---
## 1. Where we are
- **Phase 1 / Stages 58.7: ✅ on `main`.** Engine core, editor framework,
input + gizmos, comprehensive UI, Reflection v2 + asset database (Stage 8.5),
and editor Play Mode (Stage 8.7).
- **Phase 1 / Stage 9 — Physics: ✅ complete on `main`.** All pieces 18c done
and promoted (eye-checked & approved 2026-06-17).
- **Phase 1 / Stage 10 — Scripting: 🚧 core done on `main`** (crate, lifecycle,
live reload, editor integration, Console, command terminal, interactive PTY
terminal — all eye-checked & approved; **richer script API**
spawn/despawn/component-edit — pure-logic, on `main`). One follow-up remains
(the editor-UX batch — GUI, needs eye-check). Piece table + what's next in §5;
details in `docs/scripting.md`.
### Stage 9 — piece status (all on `main`)
| # | Piece | Where |
|---|-------|-------|
| 1 | Component data model + module wiring: `RigidBody`/`RigidBodyKind`, `Collider`/`ColliderShape`, `PhysicsModule`, `PhysicsSettings` | `physics/` (the `oxide-physics` crate) |
| 2 | Rapier-backed sim: build the world from components, step on `FixedUpdate`, write transforms back; by-entity forces/velocities/sleep | `physics/src/world.rs` |
| 3 | Collision groups/masks via `LayerMask`, sensors, collision/trigger events (enter/stay/exit) | `physics/src/world.rs` |
| 4 | Scene queries: raycast, sphere-cast, point/overlap with `LayerMask` filtering | `physics/src/world.rs` |
| 5 | Joints/constraints: fixed, spherical, revolute, prismatic (programmatic API) | `physics/src/world.rs` |
| 6 | Kinematic capsule character controller: move-and-slide, step offset, slope limit, grounded | `physics/src/{character,world}.rs` |
| 7 | `examples/physics_stack` + `examples/character_capsule` (headless console demos) | `examples/` |
| 8a | Editor integration: register RigidBody/Collider/CharacterController (addable, reflected) + enums; wire `PhysicsModule` into the play `App` (physics = first real consumer of Play; Stop reverts via snapshot) | `editor/src/state.rs`, `editor/src/main.rs` |
| 8b | **Collider wireframe gizmos**: box/sphere/capsule/cylinder outlines over the viewport; green=solid, amber=sensor, selected drawn thicker; matches the sim (ignores `Transform::scale`); **View ▸ Show Colliders** toggle (on by default) | `editor/src/shell.rs` |
| 8c | **Raycast debug probe**: `PhysicsWorld::sync_to_scene` (query the edited scene with no Play) + **View ▸ Raycast Probe** — click freezes a camera→cursor ray into the world (orbit to see it as a 3D line), cyan ray + magenta hit dot + normal whisker, status hint on cast. Overlay `view_proj` decoupled from selection (colliders/probe show with nothing selected) | `physics/src/world.rs`, `editor/src/{shell,main}.rs` |
| — | Bug fix: namespace every inspector field widget per component (`ui.push_id(component_name)`) so a field name shared by two components (e.g. both `MeshRenderer` and `Collider` have `shape`) can't collide egui ids and block edits | `editor/src/shell.rs` |
The `oxide-physics` crate: the **ECS is the source of truth**; the rapier world
is a transient resource rebuilt from components each step, so play-mode
snapshot/restore works for free. Full usage + internals in `docs/physics.md`.
### Key design decisions (also in memory + PLAN.md)
- **Layers = Unity "Model A"** ([[layer-group-model-decision]]): an entity is on
**one** `Layer` (single index, render/physics filter slot); multi-category
needs are served by **Groups** = the multi-valued `Tags` component + a
project-level `GroupRegistry`. **Do NOT reintroduce multi-valued `Layer`
membership.** Physics `Collider.membership`/`filter` are `LayerMask`s.
- **Component multiplicity = hybrid** ([[component-multiplicity-decision]]):
one component of a type per entity (`hecs` archetypal). For "multiple things on
one node": a **local-offset field** for naturally-single positioned things, and
**child entities** for genuine multiples. NOT internal multi-instance lists.
- **`AssetRef<T>` not `Handle<T>` in components**: a component stores a
serializable `AssetRef<T>` (= `Option<AssetUid>`); `AssetRef::resolve(db,
server)` yields the process-local `Handle<T>` at runtime. The inspector
recognises both spellings via `asset_ref_target`.
---
## 2. How we work (follow this exactly)
- **Build each stage as small, independently-tested pieces.** One piece = one
green commit. Split a piece into a pure-logic part (→ `main`) and a GUI part
(→ `dev`) when cleaner.
- **Local gate before every commit:**
`cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`.
(`#![deny(warnings)]` is on in the editor — clippy lints are hard errors, e.g.
`too_many_arguments` fires at >7 params; bundle args into tuples/structs.)
- **Branch promotion rule (from CLAUDE.md):**
- Pure-logic, fully proven by automated tests → commit to `dev` **and**
fast-forward `main`. Continue without asking. (Headless GPU pixel tests count
as proof — run them yourself.)
- Interactive GUI / rendering-feel / shell polish (anything a human must
**look at**) → push to `dev` only, **ask the maintainer to run + eye-check**,
promote after explicit approval.
- Decision rule: "Can an automated test prove this without a human looking?"
Yes → `main`-eligible. No → stop at `dev`.
- **Promotion commands** (from `dev`, after committing + pushing dev):
`git checkout main -q && git merge --ff-only dev -q && git push origin main && git checkout dev -q`
- **Commit messages** end with: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
- **Document as you build:** keep `PLAN.md`/`README.md`/`docs/` current in the
**same** change. Physics docs live in `docs/physics.md`.
### egui gotcha
egui's bundled font has a **limited glyph set**. Confirmed-rendering: `🗑 ⚙ ✏
▾ … ↺`. Confirmed-tofu (avoid): `✕` (U+2715), `⧉`, `⣿` (braille). Reuse an icon
already in the codebase or test it before shipping; prefer plain text if unsure.
Also: egui assigns widget ids from a hash of the call path + a salt — when the
same logical widget appears twice (two components with a same-named field), scope
it under `ui.push_id(unique_key)` or it errors and edits silently break.
---
## 3. Conventions (match the existing code)
- Heavy rustdoc: each public type/fn gets a one-line summary + the "why".
Module-level `//!` docs explain the piece's role.
- Unit tests in `#[cfg(test)] mod tests` at the bottom of each file; integration
tests in `tests/src/lib.rs`. Even for visual pieces, factor the math into pure
helpers and unit-test those (e.g. `collider_wire_segments`/`push_arc` in 8b),
so only the painting itself needs the eye-check.
- Serialization is `serde` + RON throughout. New optional fields use
`#[serde(default, skip_serializing_if = ...)]` so older documents still parse.
- Errors are `thiserror` enums with specific variants.
- Reflection: **public fields only**; `#[reflect(skip)]` drops a public field;
`#[reflect(min=, max=)]` on an f32 → slider. Register editor-visible types in
`oxide_editor::state::register_builtin_types`; addable ones via
`register_addable::<T>` (needs `Default`); enums via `register_enum::<E>`.
- New deferred ideas → `PLAN.md` backlog, not the current piece.
---
## 4. Key architecture facts
```rust
// Per-field reflection on any component (zero per-type editor code):
#[derive(Reflect, Serialize, Deserialize)]
struct Timer { pub repeating: bool, pub duration: f32, #[reflect(skip)] pub elapsed: f32 }
registry.register_reflected::<Timer>("Timer"); // editor + scripts
// Inspector renders generically: components_on -> field_infos -> get_field/set_field,
// edits routed through SetFieldCmd (undoable, drag-coalesced).
```
- **`oxide_engine::reflect`**: whole-value (`get_ron`/`set_ron`) + per-field
(`Reflect` + `register_reflected`) layers. `register_addable` adds an "Add
Component" constructor; `register_enum` lists variants for combo widgets.
- **`oxide_engine::prefab`**: `Prefab { name, components }`,
`ComponentSpec { type_name, ron }`; editor seeds built-ins (Empty/Cube/…).
- **`oxide_engine::layer`**: single-valued `Layer { index }`; `LayerMask`
(filter); `LayerRegistry` (names); `Tags` + `GroupRegistry` (groups).
- **Editor inspector** (`oxide_editor::shell::ShellTabViewer`): node-baked
section (Layer/Groups/Transform) then modular components (drag-reorder,
enable/disable, remove, Add Component menu). `field_widget` dispatches on
`FieldInfo.type_name`.
- **Viewport overlay** (`editor/src/shell.rs`): the host feeds a
`ViewportOverlay { view_proj, gizmo_size }` each frame; the Viewport tab paints
2D over the 3D scene via `project(world, &view_proj, tab_rect) -> Pos2`.
Transform gizmo handles, the play-state border, **and the new collider
wireframes** all paint this way. Reuse `project` + `painter.line_segment` for
any new world-space overlay (e.g. piece 8c's raycast viz).
- **Play loop** (`oxide_editor::main::drive_play` + `oxide_engine::scene::
SceneSnapshot`): owns a play `App` (DefaultModules + PhysicsModule +
ScriptModule; **`app.assets` is set to a clone of the editor's `AssetServer`**
and the project `AssetDatabase` is inserted as a resource so scripts resolve +
live-reload reaches a playing scene), built on Play / dropped on Stop, **swaps
`state.scene` in/out per tick** so the editor scene stays the single source of
truth. Snapshot covers reflected components + `Tags`/`DisabledComponents`. Undo
cleared on Play/Stop.
- **Host input** (`oxide_editor::main`): viewport orbit/pan/zoom + WASD gated on
`cursor_over_viewport && !pointer_over_floating`.
---
## 5. Exactly what to do next — finish Stage 10 (Scripting)
**Stage 10's core is done and on `main`** (all eye-checked & approved). The
`oxide-script` crate is the worked-out twin of `oxide-physics` (same
"ECS-as-truth, transient runtime resource, snapshot-for-free" shape). Full usage
+ internals: `docs/scripting.md`. What shipped (all on `main`):
| Piece | Where |
|-------|-------|
| `oxide-script` crate: `Script`, `ScriptAsset` + `.rhai` loader (`AssetKind::Script`), sandboxed `ScriptEngine` (compile/run, op cap, `ScriptError`), `ScriptModule` | `script/` |
| Lifecycle: `ScriptHost` + `run_scripts` (Update schedule); `init`/`update(dt)`; **engine API** (`bridge.rs`) — scripts read/write their `Transform` via a staged shared context (`position`/`translate`/`rotate_*`/`Vec3`), `rhai` `f32_float` | `script/src/{host,engine,bridge}.rs` |
| **Live reload**: `ScriptHost` holds each script's `Handle<ScriptAsset>` alive → watcher's in-place reload recompiles (no restart); `examples/script_spin` | `script/src/host.rs`, `examples/` |
| **Editor integration**: `Script` addable in `register_builtin_types`; `drive_play` adds `ScriptModule` + **shares the editor `AssetServer`** + a db snapshot → live `.rhai` edits update a *playing* scene | `editor/src/{state,main}.rs` |
| **Console**: capturing logger mirrors the `log` stream into a ring buffer the panel renders (script `print` + paused-script errors via `target:"oxide_script"`) | `editor/src/console.rs`, `shell.rs::console` |
| **Command terminal**: `$` prompt → `sh -c <cmd>` in the project root, streams stdout/stderr into the Console | `editor/src/terminal.rs` |
| **Interactive PTY terminal**: `PanelKind::Terminal` — tabbed, auto-closes a tab when its program exits, Tab/arrows/Esc routed to the program; runs shells / TUIs / `claude` | `editor/src/pty.rs`, `shell.rs::terminal_panel` |
### Remaining for Stage 10
**1. Richer script API — ✅ DONE, on `main`.** Scripts now `spawn_entity()` /
`spawn_entity(name)`, `despawn(e)`, and `add_component`/`set_component`/
`remove_component` on any entity, plus `entity()` for their own. Implemented as a
**deferred command buffer** in `bridge.rs` (`EntityHandle` + `ScriptCommand`):
the `rhai` functions can't borrow the ECS (must be `Send + Sync`), so each call
buffers a command the host drains in `ScriptHost::apply_commands` and applies via
the reflection registry (`app.types`, `set_ron`/`add_default`/`remove`). `spawn_entity`
returns a **provisional** handle resolvable in the same frame (`spawn` is a rhai
reserved word, hence the longer name). Component edits go through RON, closing the
last PLAN round-trip criterion. Headless tests in `host.rs`/`bridge.rs`; docs in
`docs/scripting.md` ("Spawning entities and editing components").
**2. Maintainer's editor-UX batch (GUI → `dev` + eye-check).** Details in
PLAN.md "Editor-UX follow-ups" + [[editor-file-explorer-preference]]. The
maintainer asked for these after the terminal work:
- **"New Script" button** on the `Script` inspector — write a `.rhai` template
into `assets/scripts/`, register it in the db, auto-assign it to the component.
There is **no in-editor script creation today** (scripts are authored as
files). Good small first one to pull forward.
- **Open a script in an editor** — double-click / button to open a `.rhai` in an
in-editor text view or launch `$EDITOR` / a configured external editor; edits
flow back through live reload (you can also just run `$EDITOR file` in the new
PTY terminal).
- **Unity-style file explorer** in the Project panel (create/rename/move folders,
drag-in OS import, context menus) over the existing `AssetDatabase`.
- **Native New/Open-Project dialog** (`rfd`, must work on Wayland + X11) —
typing the project path by hand "is very hard to use".
**Reuse, don't reinvent:** `oxide-physics`/`oxide-script` are the template for
"new crate → `Module` + schedule → reflected/addable components → editor
registration → play-loop integration". The play loop
(`oxide_editor::main::drive_play`) runs scripts alongside physics and shares the
editor's asset server, so live reload reaches a playing scene.
**Terminal design note:** the embedded terminal is a general PTY widget
(`portable-pty` + `vt100`); run any program (incl. `claude`) from a *Shell* tab.
A "one-app launcher" was considered and rejected — it needs the same
PTY+VT+render+input stack, so the general widget is strictly better.
**Don't do these unless asked (parked in PLAN.md):** editor authoring of joint
**components** (needs a serializable entity-reference type); standalone game
"Launch" (deferred to Stage 16).
---
## 6. Quick orientation commands
```sh
cargo test # full suite (all green at handoff)
cargo test -p oxide-script # the scripting crate (lifecycle, live reload)
cargo test -p oxide-editor --lib pty # PTY terminal pure helpers
cargo run -p oxide-examples --bin script_spin # headless live-reload demo
cargo run -p oxide-editor # the editor (Console + Terminal panels)
git log --oneline -15 # recent per-piece commits
sed -n '/## Stage 10/,/## Stage 11/p' PLAN.md # Stage 10 spec + status
```
Memory files (auto-loaded each session) track decisions: `MEMORY.md` index →
`layer-group-model-decision.md` → `component-multiplicity-decision.md` →
`run-on-target-pc.md` (headless-vs-eye-check rule) →
`workflow-auto-continue.md` (don't pause between pure-logic pieces).
+1230
View File
File diff suppressed because it is too large Load Diff
+251 -2
View File
@@ -1,3 +1,252 @@
# Oxide # Oxide Engine
Oxide general purpose 3D game engine ![Oxide Engine](assets/oxide.png)
> **Notice:** This project was developed with the assistance of [Claude Code](https://claude.ai/code) (Anthropic's AI coding assistant).
> All generated code, configuration, and documentation has been reviewed and tested by the author.
> Claude Code was used as a development tool; all design decisions, requirements, and sign-offs are the author's own.
A general-purpose 3D game engine written in Rust — built to make **any** 3D game, scaling from
stylized low-poly to realistic graphics, and shipping only what each game uses.
---
## Status
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 016)**
is the general-purpose engine — everything needed to build and export any game;
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
**Stages 09 are complete on `main`.** Stage 8 shipped the engine's full
in-game UI stack — widget tree, layout, themed styling, `ab_glyph`-backed text
shaping + R8 glyph atlas, screen-space *and* world-space render passes,
hit-test + hover/focus/press router, immediate-mode event queries with typed
`WidgetValue` data binding, and the editor's visual UI canvas. **Stage 8.5**
added reflection v2 (public fields auto-appear in the inspector, no per-type
editor code), prefabs, the asset database, and layers/groups. **Stage 8.7**
added **editor play mode** — Play/Pause/Step/Stop the open scene in the
viewport (Ctrl+P / Ctrl+.), with snapshot-on-Play / bit-for-bit restore-on-Stop.
**Stage 9** added **comprehensive physics** (`oxide-physics` on `rapier3d`) —
rigid bodies, colliders, collision/trigger events, scene queries (raycast/
shape-cast/overlap), joints, a kinematic character controller, and editor
integration (addable components, collider wireframe gizmos, a freeze-on-click
raycast debug probe). **Stage 10 — Scripting, Live Reload & Editor Terminal**
(`rhai`, the `oxide-script` crate) is now **in progress**: the `Script`
component, the `.rhai` asset loader, and the sandboxed script engine have
landed; lifecycle execution, live reload, and the editor terminal follow.
Available now:
- `oxide_engine::math``Transform`, `Aabb`, `Ray`, `Plane`, `Frustum`, `Color`, `Rect`, `Range3`
- `oxide_engine::window` — window creation, `WindowApp` trait + event loop, raw input events (`winit`)
- `oxide_engine::render` — GPU setup (`wgpu`), surface management, clear loop, and a forward
renderer: `Mesh`/`Vertex` (+ cube/plane/sphere primitives), `Material`, `Camera`, `ForwardRenderer`;
data-driven `RenderPipeline` (`RenderPass`/`ClearPass`/`ForwardPass`)
- `oxide_engine::scene``Scene`, `Node`, entity hierarchy with world-transform resolution, RON serialization (`hecs`)
- `oxide_engine::app` — the `App` core, `Module`/`DefaultModules`, and the `Schedule` (system phases + fixed timestep)
- `oxide_engine::layer``LayerMask`, `LayerRegistry`, and `Layers`/`Tags` components (shared filtering primitive)
- `oxide_engine::reflect``TypeRegistry` for generic, name-keyed component access (dual-editability)
- `oxide_engine::asset``AssetServer` + ref-counted `Handle<T>` (dedup, background load, reload by path)
- `oxide_engine::project``Project` (create/open/save, folder layout, enabled modules, per-project
settings) + `RecentProjects` MRU list
- `oxide_engine::settings` — typed `Settings` sections (engine/editor/per-module), export/import (RON)
- `oxide_engine::watch``FileWatcher` with a debounced/deduplicated change-event stream and
`reload_changed_assets` helper that drives `AssetServer::reload_path`
- `oxide_editor::shell::Shell` — docking shell (menu bar, dock area, status bar, Preferences window);
`oxide_editor::command` / `commands``CommandStack` + `SetTransformCmd` (drag-coalesce) /
`RenameCmd`; `oxide_editor::extension` — module → editor `EditorModule` extension API
- `oxide_engine::input` — per-frame `InputState` (keyboard / mouse / cursor / scroll, edge
detection), remappable `ActionMap` with `Binding` / `AxisBinding` / `Axis2DBinding`, RON-persistable
`ActionOverrides` (Stage-7 piece 13)
- `oxide_editor::gizmo` — pure-logic transform-gizmo math (hit testing, drag projection, snap);
`oxide_editor::bindings` — default editor action set (camera + gizmo hotkeys);
`oxide_editor::preferences``~/.config/oxide/editor.ron` load/save. The viewport ships a
flythrough camera (F-toggle), translate / rotate / scale gizmos with Ctrl-snap and undo, and
an Input Bindings preferences page (Stage-7 pieces 46)
- `oxide_engine::ui` — in-game UI system (Stage-8 pieces 16): `Widget` tree
with stack / grid / anchor layouts, DPI-aware sizing, per-widget visual
styles with named-style `Theme` cascade, `ab_glyph`-backed text shaping +
shelf-packed R8 `GlyphAtlas`, `paint()``DrawCommand`s consumed by
screen-space and world-space (`UiPanel`) `UiOverlayPass` in
`oxide_engine::render`, hit-test + hover/focus/press `Router` with
immediate-mode `RouterFrame::clicked_left(...)` queries, typed
`WidgetValue` (Bool / Int / Float / Text) for game-data round-tripping.
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
piece 7)
## Features (planned — see [PLAN.md](PLAN.md))
**Phase 1 — the general-purpose engine:**
- ✅ Math & core primitives (transforms, bounds, rays, frustum culling)
- ✅ Window, GPU context & clear-color render loop (`winit` + `wgpu`)
- ✅ Scene graph and entity management (ECS-based, `hecs`)
- ✅ Basic 3D rendering (meshes, PBR-lite materials, camera, GLTF import, editor viewport)
- ✅ Engine core framework: module/plugin system, layers & tags, asset server, reflection registry,
data-driven render pass pipeline
- ✅ Editor framework & project system: top menu, dockable panels, undo/redo, module extension API,
settings/preferences, create/open/save projects with live file watching
- ✅ Input system with remappable named actions (per-key edges + button/axis actions; RON-persisted
remap surfaced through the editor's Input Bindings preferences page) and editor transform gizmos
(translate / rotate / scale with Ctrl-snap and undo, W/E/R hotkeys, flythrough viewport camera)
- ✅ Comprehensive in-game UI system (widgets, layout, theming, text) authored in a visual editor canvas
- ✅ Reflection-driven inspector, prefabs, asset database, layers/groups (Stage 8.5)
- ✅ Editor play mode: Play/Pause/Step/Stop with snapshot-on-Play / restore-on-Stop (Stage 8.7)
- ✅ Comprehensive rigid-body physics (`rapier3d`): colliders, joints, scene queries, collision/
trigger events, kinematic character controller, editor integration + collider/raycast gizmos (Stage 9)
- Scripting with live reload + an in-editor terminal (host tools/AI agents that edit game code live)
- Skeletal animation · GPU-driven particles · shader hot-reload & scalable post-processing
- Standard audio (mixer/spatial)
- Built-in content kit: prototyping primitives, shaders, and a character controller
- Game export to standalone **Linux and Windows** binaries
- **In-engine editor** (`oxide-editor`) built alongside the engine
**Phase 2 — optional built-in modules (feature-gated):**
- Ray-traced spatial audio (wave propagation, occlusion, reverb)
- Developer console & cheats
- Procedural toolkit (noise + composable modifier stack)
- Terrain: generate, sculpt & paint with brushes, scatter grass/trees/objects
- Open world streaming (chunking, async asset loading, LOD)
- Pathfinding & NPC AI (navmesh, agents, behavior trees, perception)
- Water (rendering + buoyancy/swim/flow mechanics)
---
## Requirements
- Rust stable toolchain (`rustup` recommended)
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
game export are planned (see [PLAN.md](PLAN.md), Stage 16); other platforms are not yet tested.
- A GPU with Vulkan or Metal support (for `wgpu`)
Install Rust if you don't have it:
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
---
## Building
```sh
cargo build --release
```
To build and run the editor directly:
```sh
cargo run -p oxide-editor --release
```
### Examples
Each stage ships at least one runnable example. List and run them with:
```sh
cargo run -p oxide-examples --bin math_demo # Stage 1: math primitives tour
cargo run -p oxide-examples --bin hello_window # Stage 2: window + clear color (15/Space to recolor, Esc quits)
cargo run -p oxide-examples --bin scene_basic # Stage 3: build a hierarchy, print world transforms, round-trip RON
cargo run -p oxide-examples --bin hello_mesh # Stage 4: lit 3D meshes (spinning cube + sphere + ground), Esc quits
cargo run -p oxide-examples --bin ui_menu # Stage 8: themed main menu + settings (draggable slider, checkbox), Esc quits
cargo run -p oxide-examples --bin ui_hud # Stage 8: HUD (HP/ammo/minimap/crosshair) over a 3D scene, Esc quits
cargo run -p oxide-examples --bin physics_stack # Stage 9: a stack of boxes settles + a ball lands (headless console)
cargo run -p oxide-examples --bin character_capsule # Stage 9: a capsule walks, climbs a step, jumps, hits a wall (headless console)
cargo run -p oxide-examples --bin script_spin # Stage 10: a rhai script spins an entity; the script is edited live and the spin rate jumps (headless console)
```
### Benchmarks
Performance-sensitive systems have `criterion` benchmarks:
```sh
cargo bench -p oxide-engine
```
---
## Installing (Linux)
The `install.sh` script compiles the project and installs it to your system (`/usr/local`):
```sh
chmod +x install.sh
./install.sh
```
After installation the editor is available as:
```sh
oxide-editor
```
To uninstall:
```sh
sudo rm /usr/local/bin/oxide-editor
sudo rm -rf /usr/local/share/oxide
```
---
## Running tests
```sh
cargo test
```
Lint and format checks:
```sh
cargo clippy -- -D warnings
cargo fmt --check
```
---
## Documentation
The full documentation — usage guides, per-system API references, and
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md).
Start there for anything beyond this overview:
- [Getting Started](docs/getting-started.md) — build, run, test, install
- [Architecture](docs/architecture.md) — workspace and design overview
- [Conventions](docs/conventions.md) — coordinate system, units, color space
- [Development Workflow](docs/development.md) — branches, testing, contributing
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer reference
---
## Project layout
```
Oxide/
├── assets/ # Project logos and shared assets
├── docs/ # Full engine documentation
├── engine/ # Core engine library (oxide-engine)
├── editor/ # In-engine editor binary (oxide-editor)
├── examples/ # Runnable stage examples (oxide-examples)
├── tests/ # Integration test harness (oxide-tests)
├── install.sh # Build + system install script
├── PLAN.md # Staged development roadmap
└── CLAUDE.md # Context and rules for Claude Code
```
---
## Development roadmap
Development follows a staged plan — each stage is fully tested before the next begins.
See [PLAN.md](PLAN.md) for the complete roadmap.
---
## License
MIT — see [LICENSE](LICENSE).
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
{
"asset": {
"version": "2.0",
"generator": "oxide cube generator"
},
"scene": 0,
"scenes": [
{
"nodes": [
0
]
}
],
"nodes": [
{
"mesh": 0,
"name": "Cube"
}
],
"meshes": [
{
"name": "Cube",
"primitives": [
{
"attributes": {
"POSITION": 0
},
"indices": 1,
"material": 0
}
]
}
],
"materials": [
{
"name": "CubeMat",
"pbrMetallicRoughness": {
"baseColorFactor": [
0.9,
0.45,
0.12,
1.0
],
"metallicFactor": 0.0,
"roughnessFactor": 0.7
}
}
],
"buffers": [
{
"byteLength": 168,
"uri": "data:application/octet-stream;base64,AAAAvwAAAL8AAAC/AAAAPwAAAL8AAAC/AAAAPwAAAD8AAAC/AAAAvwAAAD8AAAC/AAAAvwAAAL8AAAA/AAAAPwAAAL8AAAA/AAAAPwAAAD8AAAA/AAAAvwAAAD8AAAA/BAAFAAYABAAGAAcAAQAAAAMAAQADAAIABQABAAIABQACAAYAAAAEAAcAAAAHAAMAAwACAAYAAwAGAAcAAAABAAUAAAAFAAQA"
}
],
"bufferViews": [
{
"buffer": 0,
"byteOffset": 0,
"byteLength": 96,
"target": 34962
},
{
"buffer": 0,
"byteOffset": 96,
"byteLength": 72,
"target": 34963
}
],
"accessors": [
{
"bufferView": 0,
"componentType": 5126,
"count": 8,
"type": "VEC3",
"min": [
-0.5,
-0.5,
-0.5
],
"max": [
0.5,
0.5,
0.5
]
},
{
"bufferView": 1,
"componentType": 5123,
"count": 36,
"type": "SCALAR"
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+68
View File
@@ -0,0 +1,68 @@
# Oxide Engine Documentation
This directory is the canonical, in-depth documentation for the Oxide engine. The
top-level [`README.md`](../README.md) is a short overview; everything detailed —
usage guides, API references, and explanations of how the engine works
internally — lives here and grows with the engine at every stage.
## How this documentation is organized
| Document | What it covers |
|----------|----------------|
| [getting-started.md](getting-started.md) | Installing the toolchain, building, running examples, running tests and benchmarks |
| [architecture.md](architecture.md) | Workspace layout, crate responsibilities, design philosophy, staged development model |
| [conventions.md](conventions.md) | Coordinate system, handedness, units, color space, and other cross-cutting conventions |
| [development.md](development.md) | Branch workflow (`dev`/`main`), testing protocol, documentation policy, how to add a stage |
| [math.md](math.md) | Full reference for the `oxide_engine::math` module (Stage 1) |
| [windowing.md](windowing.md) | Window creation, the `App` trait and event loop, raw input events (Stage 2) |
| [render-context.md](render-context.md) | GPU acquisition, surface configuration, the clear-color frame loop (Stage 2) |
| [scene.md](scene.md) | Scene graph, entities, the transform hierarchy, and serialization (Stage 3) |
| [rendering.md](rendering.md) | Meshes, materials, camera, and the forward renderer (Stage 4) |
| [layers.md](layers.md) | Layers, groups & tags: single-valued `Layer` + multi-valued `Tags`/`GroupRegistry`, the shared `LayerMask` filter primitive (Stage 5) |
| [reflection.md](reflection.md) | Reflection / type registry: generic name-keyed component access for dual-editability (Stage 5) |
| [prefabs.md](prefabs.md) | Prefabs: data-driven named spawn templates (component specs applied via the registry) (Stage 8.5) |
| [assets.md](assets.md) | Asset server & handles: ref-counted loading, dedup, loaders, background load, reload (Stage 5) |
| [modules.md](modules.md) | App, modules & scheduling: composing the engine, system phases, fixed timestep, enable/disable/remove (Stage 5) |
| [render-pipeline.md](render-pipeline.md) | Data-driven render pass pipeline: composable passes, scalable fidelity, camera layer visibility (Stage 5) |
| [projects.md](projects.md) | Project system: project file, folder layout, create/open/save, recent projects (Stage 6) |
| [settings.md](settings.md) | Settings & preferences framework: typed sections, export/import, per-module/per-project settings (Stage 6) |
| [file-watching.md](file-watching.md) | File-watcher foundation: debounced/deduplicated change events; asset live-reload wiring (Stage 6) |
| [editor-extensions.md](editor-extensions.md) | Module → editor extension API: how a module contributes menus, panels, tools, inspectors, settings pages (Stage 6) |
| [editor-shell.md](editor-shell.md) | Editor docking shell: menu bar, dockable panels, status bar, Preferences window, command stack + file watcher wiring (Stage 6) |
| [input.md](input.md) | Input system: per-frame `InputState` with edge detection, named action mapping (`ActionMap`/`Binding`) with defaults + remapping + RON persistence, 1D/2D directional axes (Stage 7) |
| [ui.md](ui.md) | In-game UI: widget tree, layout (stack/grid/anchor), DPI-aware sizing, per-widget visual styles + named-style themes, RON dual-edit, ab_glyph-backed text shaping + R8 glyph atlas, screen-space + world-space `UiOverlayPass`, hit-test + hover/focus/press `Router`, immediate-mode event queries + typed `WidgetValue`s (Stage 8 — pieces 16, GUI tail coming) |
| [play-mode.md](play-mode.md) | Editor play mode: `PlayState`, registry-aware `SceneSnapshot` snapshot/restore, the `tick_for` decision + `App::step`, the scene-swap runner, toolbar/shortcuts/viewport tint (Stage 8.7) |
| [physics.md](physics.md) | Physics module (`oxide-physics`, rapier3d): `RigidBody`/`Collider` components, `LayerMask`-filtered collision, the ECS-as-source-of-truth model, module wiring (Stage 9) |
| [scripting.md](scripting.md) | Scripting module (`oxide-script`, rhai): the `Script` component, `ScriptAsset` + `.rhai` loader, the sandboxed `ScriptEngine` wrapper, module wiring; ECS-as-source-of-truth + live-reload model (Stage 10) |
## Documentation status by stage
Documentation is written alongside the code. A stage is not considered done until
its docs exist here.
| Stage | Subject | Docs |
|-------|---------|------|
| 0 | Project foundation | [architecture.md](architecture.md), [getting-started.md](getting-started.md) |
| 1 | Math & core primitives | [math.md](math.md) |
| 2 | Window & render context | [windowing.md](windowing.md), [render-context.md](render-context.md) |
| 3 | Scene graph & entity system | [scene.md](scene.md) |
| 4 | Basic 3D rendering | [rendering.md](rendering.md) |
| 5 | Engine core framework | [modules.md](modules.md), [layers.md](layers.md), [reflection.md](reflection.md), [assets.md](assets.md), [render-pipeline.md](render-pipeline.md) |
| 6 | Editor framework & project system | [projects.md](projects.md), [settings.md](settings.md), [file-watching.md](file-watching.md), [editor-extensions.md](editor-extensions.md), [editor-shell.md](editor-shell.md) |
| 7 | Input system | [input.md](input.md) |
| 8 | Comprehensive UI system (in progress) | [ui.md](ui.md) |
| 8.7 | Editor play mode | [play-mode.md](play-mode.md) |
| 9 | Physics integration (in progress) | [physics.md](physics.md) |
| 10 | Scripting, live reload & editor terminal (in progress) | [scripting.md](scripting.md) |
| 11+ | — | _added as each stage lands_ |
## Conventions for these files
- One topic per file; keep files focused and link between them rather than
duplicating content.
- Every public type or system gets: a one-line summary, when to use it, and at
least one runnable code snippet.
- Code snippets are written so they would compile against the current API. When
the API changes, update the snippet in the same change.
- Prefer explaining the *why* (design intent, trade-offs) over restating the
*what* (which the rustdoc comments already cover).
+157
View File
@@ -0,0 +1,157 @@
# Architecture
This document explains how Oxide is structured and the principles that govern how
it is built. For per-system detail, see the topic documents (e.g. [math.md](math.md)).
## Goals
Oxide is a general-purpose 3D game engine written in Rust, built to make **any**
3D game — scaling from stylized low-poly to realistic graphics — and to **ship
only what each game uses**. It is built in two phases: a **general-purpose engine**
(Phase 1, scene graph, render pass pipeline, input, UI, physics, scripting,
animation, particles, shaders, audio, content kit, and game export) and a set of
optional, feature-gated **built-in modules** (Phase 2: ray-traced sound, developer
console, procedural toolkit, terrain, open world, pathfinding/AI, water). The
guiding idea is **build tools, not games**, all driven through a first-class
**in-engine editor**. See [`PLAN.md`](../PLAN.md) for the staged roadmap.
## Design philosophy
These principles are non-negotiable and shape every decision:
1. **Build in stages.** Each stage produces a usable, standalone artifact and
must be fully tested and stable before the next begins. See
[development.md](development.md) and [`PLAN.md`](../PLAN.md).
2. **Composability.** Every system should be usable independently of the others.
You should be able to pull in the math module, or the scene graph, without
dragging in the renderer.
3. **Correctness and clarity over premature optimization.** Optimize when a
benchmark says to, not before.
4. **Minimal public surface.** Internal complexity is fine; the external API
should be small and clean. Modules expose a curated set of types through
`pub use`, not their whole internal structure.
5. **No feature creep between stages.** New ideas go to the backlog, not into the
current stage.
6. **The editor grows with the engine.** `oxide-editor` is a first-class
deliverable, gaining panels and tools as each stage adds systems.
7. **Build tools, not games.** Ship composable building blocks; genre-specific
behavior belongs in game code or optional modules.
8. **Ship only what's used.** Subsystems are feature-gated **modules** (from
Stage 5); an exported game compiles in only the modules it registers.
9. **Scalable fidelity.** A data-driven render pass pipeline lets a project run
anything from a flat low-poly/stylized look to a full realistic stack, paying
only for the passes it enables.
10. **Modules are the primary extension point.** A module registers engine logic
*and* editor UI *and* its own settings through one documented API; anyone —
including AI agents — can write one.
## Workspace layout
Oxide is a single Cargo workspace. Crates share version, edition, license, and
dependency versions through `[workspace.package]` and `[workspace.dependencies]`
in the root `Cargo.toml`.
```
Oxide/
├── engine/ # oxide-engine — the core library (all engine systems)
├── editor/ # oxide-editor — the in-engine editor binary
├── examples/ # oxide-examples — runnable examples, one+ per stage
├── tests/ # oxide-tests — integration / end-to-end test harness
├── docs/ # this documentation
├── assets/ # logos and shared assets
├── install.sh # release build + system install
├── PLAN.md # authoritative staged roadmap
├── README.md # short project overview
└── CLAUDE.md # rules and context for AI-assisted development
```
### Crate responsibilities
- **`oxide-engine`** — the library that contains every engine system. It is
organized as one module per system (`math`, and later `scene`, `render`,
`physics`, …). Each module is independently usable and re-exports its public
types. A `prelude` module collects the most common imports.
- **`oxide-editor`** — the binary users run. It depends on `oxide-engine` and
builds a UI on top of engine systems (its own window with a placeholder
viewport since Stage 2; egui panels from Stage 3). It never contains engine
logic itself; it is a consumer of the engine.
- **`oxide-examples`** — small, focused programs that each demonstrate one
stage's capabilities. They double as manual-review artifacts and as living
documentation. `publish = false`; they are never installed.
- **`oxide-tests`** — integration tests that exercise the engine the way a real
consumer would, including cross-module scenarios and fuzz/property tests.
## Engine module structure
Inside `oxide-engine`, each system is a module under `engine/src/`. The pattern,
established by the math module, is:
```
engine/src/
├── lib.rs # declares modules, defines the prelude
├── math/
│ ├── mod.rs # module docs + curated `pub use` re-exports
│ ├── transform.rs # one type/concept per file, with its own tests
│ ├── aabb.rs
│ └── ...
├── render/ # Stage 2: GPU acquisition + surface clear loop
│ ├── mod.rs # RenderError, clear_view, re-exports
│ ├── gpu.rs # Gpu (instance/adapter/device/queue)
│ └── context.rs # RenderContext (surface, resize, render_frame)
└── window/ # Stage 2: window + event loop + App trait
├── mod.rs # WindowConfig, `event` re-export module
├── app.rs # App trait, AppCtx
└── runner.rs # winit ApplicationHandler internals
```
Rules of thumb:
- **One concept per file.** Prefer many small focused files over a few large
ones.
- **Tests live with the code.** Each file has a `#[cfg(test)] mod tests` block
covering core behavior and edge cases.
- **The module root curates the API.** `mod.rs` decides what is public via
`pub use`; submodules are private (`mod foo;`, not `pub mod foo;`) unless there
is a reason to expose the path.
- **The prelude is the front door.** `oxide_engine::prelude::*` brings in the
types a typical consumer needs, including re-exported third-party math types so
downstream code needs only one dependency for everyday work.
## Key dependencies
Chosen for portability and a lightweight footprint:
| Concern | Crate | Notes |
|---------|-------|-------|
| Math | [`glam`](https://docs.rs/glam) | SIMD-friendly vectors/quats/matrices; `serde` feature enabled |
| Graphics | [`wgpu`](https://docs.rs/wgpu) | Portable across Vulkan/Metal/DX12 (since Stage 2; re-exported as `oxide_engine::wgpu`) |
| Windowing | [`winit`](https://docs.rs/winit) | Cross-platform windows and events (since Stage 2; re-exported as `oxide_engine::winit`) |
| ECS | [`hecs`](https://docs.rs/hecs) | Lightweight archetypal ECS (Stage 3+) |
| Physics | [`rapier3d`](https://docs.rs/rapier3d) | Rigid bodies and collision (Stage 6+) |
| Editor UI | [`egui`](https://docs.rs/egui) | Immediate-mode UI (Stage 3+) |
| Logging | `log` + `env_logger` | Facade + env-driven backend |
| Errors | `anyhow` + `thiserror` | Application vs. library error handling |
| Serialization | `serde` + `ron` | Scene/asset (de)serialization (Stage 3+) |
## Error handling and logging
- **Libraries (`oxide-engine`)** define their own error types with `thiserror`
so callers can match on failure modes.
- **Binaries (`oxide-editor`, examples)** use `anyhow` for ergonomic error
propagation at the top level.
- **Logging** uses the `log` facade throughout the engine; binaries initialize a
backend (`env_logger`). Control verbosity with `RUST_LOG`, e.g.
`RUST_LOG=oxide_engine=debug cargo run -p oxide-examples --bin math_demo`.
## Installability
Oxide must remain installable as a Linux package at all times. `install.sh`
builds in release mode and installs the `oxide-editor` binary and assets under a
prefix (`/usr/local` by default, overridable with `PREFIX`). Any new installed
binary or asset must be reflected in `install.sh` in the same change.
## See also
- [conventions.md](conventions.md) — coordinate system, units, color space
- [development.md](development.md) — workflow, testing, and how stages progress
- [`PLAN.md`](../PLAN.md) — the full staged roadmap
+221
View File
@@ -0,0 +1,221 @@
# Asset Server & Handles
`oxide_engine::asset` is the engine's central way to load and own external data
(meshes, and later textures, audio, fonts, …). It lands in Stage 5 and underpins
three later stages: live reload (Stage 10), open-world streaming (Stage 21), and
export packing (Stage 16). Putting it in early means later loaders plug into one
system instead of each inventing its own.
## Handles own assets
The unit of ownership is a [`Handle<T>`]: a typed, reference-counted reference to
a loaded asset. It is cheap to clone (an `Arc` bump), and the asset behind it
lives exactly as long as at least one handle does. The [`AssetServer`] keeps only
a `Weak` reference in its dedup cache, so it never keeps an otherwise-unused
asset alive — drop the last handle and the asset is freed.
```rust
use oxide_engine::asset::{AssetServer, GltfModel, Handle};
let assets = AssetServer::new(); // built-in loaders (glTF) registered
let model: Handle<GltfModel> = assets.load("assets/models/cube.gltf");
if let Some(model) = model.get() { // Option<Arc<GltfModel>>, None until loaded
println!("{} meshes", model.meshes.len());
}
```
Key handle methods:
| Method | Meaning |
|--------|---------|
| `get()` | `Some(Arc<T>)` once loaded, else `None` (cheap clone of the value `Arc`) |
| `state()` / `is_loaded()` | lifecycle: `Loading` / `Loaded` / `Failed` |
| `wait()` | block until ready; `Some(value)` or `None` on failure |
| `error()` | the failure message, if any |
| `source()` | the path it was loaded from (in-memory assets have none) |
| `ref_count()` | number of live handles (the server holds none) |
## Deduplication and freeing
Loading the same path+type twice returns handles to **one** shared asset — the
loader runs once:
```rust
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
# let assets = AssetServer::new();
let a: Handle<GltfModel> = assets.load("model.gltf");
let b: Handle<GltfModel> = assets.load("model.gltf");
assert_eq!(a.id(), b.id()); // same underlying asset
assert_eq!(assets.live_asset_count(), 1);
```
When the last handle drops, the weak cache entry dies and the asset is collected;
`live_asset_count()` prunes such entries as it counts.
## Synchronous vs background loading
`load` blocks until the asset is ready. `load_async` returns immediately with a
handle in the `Loading` state and fills it on a background thread:
```rust
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
# let assets = AssetServer::new();
let handle: Handle<GltfModel> = assets.load_async("big.gltf");
// ... do other work while it loads ...
let model = handle.wait(); // or poll handle.state()
```
`add(value)` stores an already-constructed, in-memory asset (no path, not
deduplicated) and returns a handle to it — useful for procedurally generated or
test data.
## Writing a loader
Formats are pluggable via the [`AssetLoader`] trait: declare the output type and
the extensions, and implement `load`. Register it with
`server.register_loader(...)`; the server dispatches by extension and verifies
the loader's output type matches the requested `T`.
```rust
use std::path::Path;
use oxide_engine::asset::{AssetError, AssetLoader};
struct TextLoader;
impl AssetLoader for TextLoader {
type Asset = String;
fn extensions(&self) -> &'static [&'static str] { &["txt"] }
fn load(&self, path: &Path) -> Result<String, AssetError> {
std::fs::read_to_string(path).map_err(|e| AssetError::Load {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
let assets = oxide_engine::asset::AssetServer::empty(); // no built-ins
assets.register_loader(TextLoader);
```
The built-in [`GltfLoader`] is exactly this pattern wrapping the Stage-4
[`load_gltf`] importer; `AssetServer::new()` registers it for `.gltf`/`.glb`.
Errors are specific — `NoExtension`, `NoLoader`, `TypeMismatch`, and `Load` — so
callers can tell "no loader" from "the file is broken".
## Live reload foundation
`reload(path)` re-runs the loader and updates the existing asset **in place**, so
every live handle observes the new contents on its next `get()`:
```rust
# use oxide_engine::asset::{AssetServer, GltfModel, Handle};
# let assets = AssetServer::new();
let model: Handle<GltfModel> = assets.load("model.gltf");
// ... the file changes on disk ...
let same = assets.reload::<GltfModel>("model.gltf");
assert_eq!(same.id(), model.id()); // same asset, new data
```
Stage 10 wires this to the file watcher to hot-reload changed assets while the
editor runs.
## Asset database — stable, project-relative references
The [`AssetServer`] loads by *path*, but a scene or UI document must not bake an
**absolute** system path into its saved data — that breaks the moment the
project moves to another directory or machine, and it is the chief obstacle to a
clean game export (Stage 16). The [`AssetDatabase`] is the bridge.
Every imported asset lives under a **typed subfolder** of the project's
`assets/` directory and gets a stable [`AssetUid`]. The database records, per
project, the mapping **`AssetUid` ↔ assets-relative path** (e.g.
`"fonts/Inter-Regular.ttf"`) in a manifest at the project root
(`assets.manifest`). Saved documents reference assets by `AssetUid`; resolving a
uid yields the relative path, which combined with the *current* project root
gives an absolute path the server loads and deduplicates.
```rust
use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel};
let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present
db.scan(); // discover files in fonts/ models/ …
db.save().unwrap(); // persist any newly-assigned uids
let uid = db.uid_of("models/cube.glb").unwrap();
let server = AssetServer::new();
let model = db.load::<GltfModel>(&server, uid); // Option<Handle<GltfModel>>
```
Because the stored mapping is purely relative, a reference resolves to the **same
handle** across save/load *and* after the whole project directory moves — open
the database from the new location (or call `set_root`) and every uid keeps
resolving. The uid layer (rather than referencing by relative path directly)
also lets an asset be renamed or moved *within* the project later without
breaking references, since the uid travels with the file in the manifest.
### Typed folders
[`AssetKind`] fixes each asset's subfolder and the extensions that belong to it:
| Kind | Folder | Extensions |
|------|--------|-----------|
| `Font` | `fonts/` | `ttf`, `otf` |
| `Texture` | `textures/` | `png`, `jpg`, `jpeg`, `tga`, `bmp`, `dds`, `ktx2` |
| `Model` | `models/` | `gltf`, `glb`, `obj` |
| `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` |
| `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) |
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
file extension as a fallback for files dropped directly in `assets/`.
### Asset-reference fields in the inspector
A component points at an asset with an [`AssetRef<T>`] field — **not** a live
`Handle<T>`. A handle is process-local and not serializable, so persisting one
would be wrong; an `AssetRef<T>` is a thin, serializable wrapper over
`Option<AssetUid>` that resolves to a handle on demand:
```rust
use oxide_engine::asset::{AssetRef, AssetServer, AssetDatabase};
use oxide_engine::ui::Font;
#[derive(serde::Serialize, serde::Deserialize)]
struct Label { font: AssetRef<Font> } // serializes as just the uid
# fn demo(label: &Label, db: &AssetDatabase, server: &AssetServer) {
let handle = label.font.resolve(db, server); // Option<Handle<Font>>
# }
```
Because `AssetRef<T>` round-trips through reflection's RON path, the field is
editable in the inspector with **no per-type code**. Its
[`type_name`](../engine/src/reflect.rs) is the syntactic spelling
`"AssetRef < Font >"`; [`asset_ref_target`] unwraps that to the target type name
(`"Font"`), and [`AssetKind::for_handle_target`] maps it to the kind the editor's
asset picker filters by — so selecting a UI element and picking a font lists only
the assets under `fonts/`. A bare `Handle<T>` field (e.g. on a non-persisted
type) is recognised the same way.
### In the editor
`EditorState` holds an `asset_db` whenever a project is open: the editor opens
and scans it on project open/create and rescans when the file watcher reports
changes under `assets/`, so importing an asset is just **dropping the file into
the matching typed folder** (`fonts/`, `textures/`, …) — no separate import
step. The **Project panel** is an asset browser listing each typed folder's
assets from the database, and an asset-reference field in the inspector renders
as a **picker** populated from it, filtered to the field's target kind. New
projects are seeded with a bundled default UI font (Inter, SIL OFL) under
`fonts/`, referenced by its project-relative path like any other asset.
[`AssetDatabase`]: ../engine/src/asset/database.rs
[`AssetKind`]: ../engine/src/asset/database.rs
[`AssetUid`]: ../engine/src/asset/database.rs
[`AssetRef<T>`]: ../engine/src/asset/database.rs
[`asset_ref_target`]: ../engine/src/asset/database.rs
[`AssetKind::for_handle_target`]: ../engine/src/asset/database.rs
[`Handle<T>`]: ../engine/src/asset/handle.rs
[`AssetServer`]: ../engine/src/asset/server.rs
[`AssetLoader`]: ../engine/src/asset/server.rs
[`GltfLoader`]: ../engine/src/asset/gltf.rs
[`load_gltf`]: ../engine/src/asset/gltf.rs
+90
View File
@@ -0,0 +1,90 @@
# Conventions
Cross-cutting conventions every Oxide system follows. These are decided once,
here, so individual systems don't each invent their own.
## Coordinate system
Oxide uses a **right-handed** coordinate system, consistent with `glam`'s
`*_rh` matrix constructors and the glTF asset format the engine will load.
In a default (identity) orientation:
| Axis | Direction | Local accessor on `Transform` |
|------|-----------|-------------------------------|
| `+X` | right | `Transform::right()` |
| `+Y` | up | `Transform::up()` |
| `-Z` | forward (the direction a camera/object looks) | `Transform::forward()` |
So **forward is `-Z`**. This matches the convention used by glTF, OpenGL, and
`glam`'s view-matrix helpers, which keeps asset import and camera math
consistent.
`Transform::looking_at(eye, target, up)` produces an orientation whose
`forward()` points from `eye` toward `target`.
## Rotations
- Rotations are stored as **unit quaternions** (`glam::Quat`), not Euler angles
or matrices, to avoid gimbal lock and accumulate cleanly under composition.
- Euler-angle helpers (`Quat::from_euler`) are available for authoring, but the
canonical stored form is always a quaternion.
- Angles are in **radians**. Convert from degrees explicitly at the boundary
(`90_f32.to_radians()`).
## Transform composition
- Transforms compose **parent-first**: `parent.mul_transform(&child)` yields the
child resolved in the parent's space, matching `parent_matrix * child_matrix`.
- The effective matrix order is `T * R * S` — scale is applied first, then
rotation, then translation.
- Composition is **exact** for uniform scale. With non-uniform scale plus
rotation the true product is not representable as a single translation /
rotation / scale triple, so the result is the closest TRS approximation
(re-decomposed from the matrix). Prefer uniform scale in deep hierarchies.
See [math.md](math.md#transform) for details.
## Units
- **Length:** meters. Physics (`rapier3d`, Stage 6) tunes its solver for
meter-scale geometry, so the whole engine adopts meters to avoid conversions.
- **Time:** seconds (`f32` for per-frame deltas; a fixed timestep drives physics
from Stage 6).
- **Angles:** radians (see above).
- **Mass:** kilograms (Stage 6+).
## Numeric type
- The engine is **`f32`-first**. `glam`'s `f32` types are the default throughout;
`f64` is used only where a specific algorithm demands it.
- Comparisons use explicit epsilons rather than `==` on floats. Helpers and tests
use a small tolerance (commonly `1e-4``1e-6`) appropriate to the operation.
## Color and color space
- `Color` stores **linear** RGBA as `f32`. Lighting and blending math is correct
only in linear space, so that is the engine's working space.
- Values are nominally in `[0, 1]` but are **not clamped** — values above `1.0`
represent HDR / emissive intensity.
- Conversions to and from 8-bit **sRGB** (the space of color pickers, image
files, and `#RRGGBB` hex) are explicit: `Color::from_srgb_u8`,
`Color::from_hex`, `Color::to_srgb_u8`. Never treat raw 8-bit values as linear.
## Geometry primitives
- An [`Aabb`](math.md#aabb) is *empty* when any `min` component exceeds the
corresponding `max`; `Aabb::EMPTY` is the identity for `union`.
- A [`Ray`](math.md#ray) always stores a **normalized** direction, so its
parameter `t` is a true distance.
- A [`Plane`](math.md#plane) is stored in **Hessian normal form** (`normal·p + d
= 0`) with a unit normal; the positive half-space is the side the normal points
toward.
- A [`Frustum`](math.md#frustum) stores six planes with **inward-facing**
normals; a point is inside when it is in the positive half-space of all six.
## Determinism
Procedural systems (Stage 11+) must be **deterministic**: the same seed always
produces the same output. Tests that need randomness use a small, explicit,
seeded PRNG rather than a system RNG, so failures are reproducible.
+111
View File
@@ -0,0 +1,111 @@
# Development Workflow
How work flows through the Oxide project: branches, testing gates, documentation,
and how a stage progresses from start to sign-off.
## Branch model
Oxide uses two long-lived branches:
| Branch | Meaning |
|--------|---------|
| `dev` | Integration branch. Everything that builds and passes automated tests lands here first. |
| `main` | Stable branch. Only contains work that has been verified — automatically *and*, where relevant, manually approved. |
### The flow
1. **Work lands on `dev`.** Any change that can be **fully verified by automated
means** (it builds, and its unit / integration / fuzz tests and benchmarks
pass) is committed and pushed to `dev` as soon as it is complete and green.
2. **Manual-test gate before `main`.** If a change *cannot* be fully verified
automatically — anything involving the GUI, rendering output, audio, input
feel, or otherwise "you have to actually run the engine and look at it" — it
stops at `dev`. The maintainer runs it, reviews the behavior, and reports
back. Only after explicit approval is that version promoted to `main`.
3. **Fully-automated changes can go straight to both.** When a change is
completely covered by automated tests (e.g. the math module — pure CPU logic
with full unit/fuzz/benchmark coverage), it may be pushed to `dev` and `main`
together, because the automated suite *is* the sign-off. No manual gate is
needed.
### Deciding which path a change takes
Ask: **"Can a test prove this works without a human looking at it?"**
- **Yes** → it can go to `main` as soon as tests pass (via `dev`).
- **No** (needs eyes/ears on a running engine) → push to `dev`, request manual
testing, wait for approval, then promote to `main`.
When in doubt, treat it as needing manual testing and leave it on `dev`.
### Promoting `dev` to `main`
```sh
git checkout main
git merge --ff-only dev # or a regular merge if histories diverged
git push origin main
git checkout dev
```
## Testing protocol
Every stage must pass all of the following before it is considered done (see also
[`PLAN.md`](../PLAN.md)):
1. **Unit tests** — every module has tests for core behavior and edge cases,
living in a `#[cfg(test)] mod tests` block beside the code.
2. **Integration tests** — the `oxide-tests` crate runs end-to-end and
cross-module scenarios, including fuzz/property tests where appropriate.
3. **Example review** — each stage ships at least one runnable example in
`oxide-examples`; both the maintainer and the implementer review it.
4. **Benchmarks** — performance-sensitive systems have `criterion` benchmarks;
regressions against a stage's stated budget block sign-off.
5. **Clippy + fmt**`cargo clippy --all-targets -- -D warnings` and
`cargo fmt --check` must be clean. Engine/editor crates use
`#![deny(warnings)]`.
Quick local gate before any commit:
```sh
cargo fmt --check && \
cargo clippy --all-targets -- -D warnings && \
cargo test
```
## Documentation policy
Documentation is written **as the work is done**, not after:
- Detailed docs live in [`docs/`](README.md), one topic per file. The top-level
`README.md` stays a short overview.
- A stage is not complete until its systems are documented in `docs/` — usage
(how to call it) **and** inner workings (how/why it works).
- When an API changes, update the affected doc and its code snippets in the
**same change**, so docs never drift from the code.
- Keep the maintained project files current whenever structure, goals, or
process change: `CLAUDE.md`, `PLAN.md`, `README.md`, `.gitignore`, and the
relevant files in `docs/`.
## Adding a new stage
1. Read [`PLAN.md`](../PLAN.md) for the stage's deliverables and test criteria.
2. Implement the system as one or more focused modules under `engine/src/`
(one concept per file, tests beside the code). Add editor support if the
stage calls for it.
3. Add at least one example under `examples/src/bin/`.
4. Write the stage's documentation under `docs/` and link it from
[`docs/README.md`](README.md).
5. Ensure the full testing protocol passes.
6. Update `PLAN.md` (mark the stage complete), `README.md` (status/features),
and — if installed binaries or assets changed — `install.sh`.
7. Land on `dev`; promote to `main` per the branch model above.
## Commit conventions
- Commit messages are written in the imperative mood and describe *what* and
*why*.
- Group related changes; keep a commit focused on one logical change where
practical.
- Co-authorship trailers are added when a commit is produced with AI assistance.
+142
View File
@@ -0,0 +1,142 @@
# Editor Extension API
`oxide_editor::extension` is the editor-side companion to the engine's
[`Module`](modules.md) trait. It is the mechanism through which a module
contributes the UI it needs the editor to host on its behalf — menu items,
dockable panels, viewport tools, component inspectors, and Preferences
pages — **without editing the editor's source**.
This is *the* extension surface for both first-party modules (Stage-7 input
binding pages, Stage-9 physics inspectors, Stage-17 ray-traced-audio panels…)
and any third-party or AI-authored module.
## Why a separate trait
The engine doesn't depend on egui — putting the editor hook on
`oxide_engine::app::Module` would pull egui into the engine. Instead a module
that wants to participate in the editor implements **two** traits on the same
struct:
```rust
struct AudioPreviewModule;
impl oxide_engine::app::Module for AudioPreviewModule {
fn name(&self) -> &'static str { "audio_preview" }
fn build(&self, app: &mut oxide_engine::app::App) {
// … register systems, types, asset loaders
}
}
impl oxide_editor::extension::EditorModule for AudioPreviewModule {
fn name(&self) -> &'static str { "audio_preview" }
fn build_editor(&self, ext: &mut oxide_editor::extension::EditorExtensions) {
// … register menu items, panels, inspectors, settings pages
}
}
```
The shared name is how enable/disable in Preferences stays consistent across
the two halves: toggling `"audio_preview"` hides the editor contributions and
disables the engine systems together.
## What a module can contribute
| Kind | Helper | Stage-6 criterion |
|------|--------|-------------------|
| **Menu items** (`File/New`, `Help/About`, …) | `add_menu_item` | ✔ required |
| **Dockable panels** | `add_panel` | ✔ required |
| **Viewport tools** (gizmos, brushes) | `add_viewport_tool` | future stages |
| **Component inspectors** (by reflection name) | `add_inspector` | future stages |
| **Settings pages** (by section name) | `add_settings_page` | ✔ required |
```rust,no_run
use oxide_editor::extension::{DockLocation, EditorExtensions, EditorModule};
struct DemoModule;
impl EditorModule for DemoModule {
fn name(&self) -> &'static str { "demo" }
fn build_editor(&self, ext: &mut EditorExtensions) {
ext.add_menu_item("File/Demo…", || { /* open the demo dialog */ });
ext.add_panel("Demo Panel", DockLocation::Right, |ui| {
ui.label("hello from a module-owned panel");
});
ext.add_inspector("DemoComponent", |ui| {
ui.label("custom editor for DemoComponent");
});
ext.add_settings_page("demo", "Demo", |ui| {
ui.label("module preferences here");
});
}
}
```
The render closures take only `&mut egui::Ui` in Piece 5 (registration). Piece
6 — the docking shell — refines the signatures to pass through the editor's
runtime context (scene, selection, asset server, settings). Modules that need
shared state today can capture it through interior mutability
(`Rc<RefCell<...>>`).
## How the shell consumes the registry
```rust
# use oxide_editor::extension::{EditorExtensions, EditorModule, DockLocation};
# struct M; impl EditorModule for M {
# fn name(&self) -> &'static str { "m" }
# fn build_editor(&self, ext: &mut EditorExtensions) {
# ext.add_menu_item("File/Open", || {});
# ext.add_panel("Inspector", DockLocation::Right, |_| {});
# }
# }
let mut ext = EditorExtensions::new();
ext.add_module(M);
// What the shell will do in piece 6:
for item in ext.iter_menu_items() {
let _ = &item.path; // build the menu tree
}
for panel in ext.iter_panels() {
let _ = (&panel.name, panel.default_dock); // place in dock layout
}
```
Lookups by name are also provided (`has_inspector_for("Transform")`,
`has_settings_page_for("audio")`) so the Inspector and Preferences windows can
ask "is there a custom editor for this thing?" before rendering a fallback.
## Attribution and lifecycle
Every contribution remembers its source module. That gives three lifecycle
operations the engine `App` already has and the editor needs to mirror:
| Operation | Effect |
|-----------|--------|
| `add_module` | Runs `build_editor`, attributes every contribution to the module, marks enabled. Re-adding replaces the old registration cleanly. |
| `set_module_enabled(name, false)` | Contributions stay registered but vanish from every `iter_*` / `has_*` lookup — toggling Preferences is reversible without rebuilding state. |
| `remove_module(name)` | Drops every contribution attributed to the module in one shot. |
Adding contributions outside a module's `build_editor` panics: every entry
must be attributable to *some* module, otherwise removal would leave orphans.
## Inspector / settings-page resolution
The Inspector panel renders custom editors for components whose type has a
registered inspector — keyed by the same name the component is registered
under in the [reflection registry](reflection.md). Settings pages plug into the
[Preferences framework](settings.md) by matching their `section_name` to the
section the module registered. Both lookups respect the enabled flag, so a
disabled module's inspector / page disappears even if the underlying section
or type is still registered.
## Testing strategy
The whole API is purely about *registration*, so it's directly unit-testable
without bringing up egui — tests construct an `EditorExtensions`, add a
demo module, and assert via the lookup helpers. The actual rendering of the
contributed UI is exercised by the Piece-6 docking shell with a maintainer
manual pass; the Stage-6 criterion ("a trivial test module adds a menu item,
a panel, and a settings page through the API with no editor-core edits") is
covered by the integration test in `tests/src/lib.rs::stage6`.
[`extension`]: ../editor/src/extension.rs
[`EditorExtensions`]: ../editor/src/extension.rs
[`EditorModule`]: ../editor/src/extension.rs
+181
View File
@@ -0,0 +1,181 @@
# Editor Shell
The Stage-6 docking **shell** is the editor's host frame: the top menu bar,
the bottom status bar, the dockable panel area, the Preferences window, and
the wiring between every Stage-6 framework piece — command stack, project
system, settings, file watcher, and the
[module → editor extension API](editor-extensions.md).
Lives in [`oxide_editor::shell`](../editor/src/shell.rs) (the library) with
[`oxide-editor`](../editor/src/main.rs) (the binary) acting as glue: open a
window, run the egui paint pump, run the 3D viewport, hand events to the
shell. Splitting the shell into the library lets it be unit-tested without
spinning up a window.
## Layout
```
┌──────────────────────────────────────────────────────────────┐
│ File Edit View Project Modules Help ⚙ Prefs │ ← menu bar
├────────────┬───────────────────────────┬────────────────────┤
│ │ │ │
│ Hierarchy │ Viewport │ Inspector │
│ │ │ │
│ ├───────────────────────────┤ │
│ │ Project │ Console │ │
│ │ │ │
├────────────┴───────────────────────────┴────────────────────┤
│ Reloaded 3 asset(s) modules: 0 undo: 2 │ ← status bar
└──────────────────────────────────────────────────────────────┘
```
Panels are dockable: drag a tab to re-dock, resize splits, or pop it out as a
floating window — provided by [`egui_dock`](https://docs.rs/egui_dock). The
default layout is built once in [`Shell::default_dock`]; persisting the user's
layout across restarts is a later refinement.
## What's wired to what
| Shell surface | Backing system |
|---------------|----------------|
| File menu → New / Open / Save / Recent / Quit | [`Project`](projects.md) + `RecentProjects` + `Shell::take_quit_request` |
| Edit menu → Undo / Redo (Ctrl+Z / Ctrl+Y) | [`CommandStack`](../editor/src/command.rs) |
| View menu → Show/Hide panel toggles | dock state |
| Project menu → Open project info | `EditorState::project` |
| Modules menu → module-contributed items | [`EditorExtensions`](editor-extensions.md) |
| Help → About | static info |
| Status bar → live hint (last action, errors) | `StatusLine` (timed TTL) |
| Status bar → module / undo counters | `EditorExtensions` + `CommandStack` |
| Preferences window → settings sections + module on/off | [`Settings`](settings.md) + `EditorExtensions` |
| File watcher (on project open) → `AssetServer::reload_path` | [`FileWatcher`](file-watching.md) |
## Commands and undo
`Edit` menu shows the labels of the next undo / redo entry; `Ctrl+Z` /
`Ctrl+Y` (also `Ctrl+Shift+Z`) drive them. The shortcut router is
[`Shell::try_consume_shortcut`] — same path the menu uses, so the unit tests
exercise the real flow.
The first wired commands ([`SetTransformCmd`], [`RenameCmd`]) live in
[`oxide_editor::commands`](../editor/src/commands.rs).
[`SetTransformCmd::merge`] coalesces consecutive edits to the same entity, so
a slider drag (or a future gizmo drag) is **one** undo entry instead of one
per frame.
Structural edits (spawn / despawn / reparent / change mesh) still bypass the
stack today — round-tripping a despawn through undo needs stable entity ids,
a Stage-7 design step alongside the gizmos.
## File watcher
[`Shell::open_project`] (or `create_project`) attaches a
[`FileWatcher`](file-watching.md) over the project's `assets/`, `scenes/`, and
`scripts/` directories (~150 ms debounce window). The shell's
[`frame_tick`](../editor/src/shell.rs) pumps events through
[`reload_changed_assets`](file-watching.md) each frame, so editing a file
externally hot-reloads any handle that was already loaded. Closing the
project tears the watcher down.
Backends that don't deliver events (some sandboxed CI environments) log a
warning and let the editor keep running — the watcher is best-effort.
## Module integration
Anything a module registers via the [extension API](editor-extensions.md) is
hosted by the shell with no editor-source edits:
- **Menu items** appear under the `Modules` top menu (shown only when at
least one item is registered).
- **Panels** appear in the dock as `PanelKind::Custom(name)` tabs, rendered
through the module's `FnMut(&mut egui::Ui)` closure.
- **Settings pages** + module on/off checkboxes are surfaced in the
Preferences window's sidebar.
- **Component inspectors** (Stage 7+) will be looked up by reflection name
when the Inspector encounters a selection holding that component.
Disabling a module in Preferences hides every contribution at once but keeps
it registered — re-enabling restores it instantly, no shell rebuild.
## Why a `Shell` library
A few reasons it lives in `editor/src/shell.rs` rather than `main.rs`:
- **Unit-testable behavior.** Shortcut routing, project open/close, recent
list updates, command stack lifecycle — all exercised without a window.
The maintainer's manual pass focuses on what tests *can't* prove: how the
UI looks and feels.
- **Reusable in tests and future hosts.** A headless reproducer for a UI bug
can drive the shell directly; an alternate front-end (web, embedded) could
reuse it.
- **Separation of concerns.** The binary stays a thin runner — window event
loop, 3D viewport, egui paint pump — while the shell owns editor state,
layout, and the framework wiring.
## Viewport camera modes (Stage 7)
The viewport has two camera schemes; the active one is toggled with
**F** (the default binding for the `editor.camera.toggle_flythrough`
action) while the cursor is over the Viewport tab. Bindings live in
`EditorState::actions` ([`ActionMap`](input.md)) registered with editor
defaults at startup; the [Input Bindings](#input-bindings-preferences-page)
preferences page exposes them for remapping.
| Mode | Controls |
|------|----------|
| **Orbit** (default) | L-drag = orbit · R-drag = pan · scroll = zoom · click = pick |
| **Flythrough** | WASD = forward/back + strafe · QE = down/up · Shift = sprint · R-drag = look · scroll = adjust move speed · click = pick |
Toggling preserves pose: the new camera lands looking at the same view
the previous one was showing, so the scene doesn't snap.
## Input Bindings preferences page (Stage 7 piece 5)
The Preferences window's `input.bindings` section renders a rich page
listing every registered editor action — buttons, 1D axes, 2D axes — with
its current bindings. Each binding cell:
- Clicking it arms a **capture** for that slot. The page shows
"Press a key…"; the next non-`Escape` key or mouse button press
becomes the binding. `Escape` cancels.
- `✕` removes that binding.
- `+` (per direction or per action) starts an append capture so the user
can add a binding without replacing one.
- `↺` (per action) restores that action to its code-defined defaults.
- A global **Restore all defaults** button at the top resets every
action.
Edits flow through [`Shell`](../editor/src/shell.rs)'s
`try_complete_capture`, which mutates `EditorState::actions`, syncs the
new bindings into the `input.bindings` settings section via
`sync_action_overrides_to_settings`, and flips a `bindings_dirty` flag.
The host runner reads-and-clears the flag each frame and writes the
preferences file to `$XDG_CONFIG_HOME/oxide/editor.ron` (or
`$HOME/.config/oxide/editor.ron`). On startup the editor reads that
file, calls `Settings::import`, and `apply_action_overrides_from_settings`
layers the user's remap on top of the defaults — so a remap survives a
restart, and removing an action from code never breaks an old file
(unknown sections are silently skipped).
The page is intentionally a built-in Shell feature rather than going
through `EditorExtensions::add_settings_page`: it needs to mutate
`EditorState::actions` while a capture is in flight, which is more
direct from the Shell than through the extension API's `FnMut(&mut Ui)`
contract.
## What's not yet here
| Feature | Where it lands |
|---------|---------------|
| Native New/Open dialogs (`rfd` or similar) | Polish; the in-app text-path modals fill the gap today |
| ~~3D viewport with its own projection sized to the Viewport tab~~ | ✅ Landed in Stage 7 piece 6b: `FrameContext::viewport_rect` restricts the wgpu viewport and drives the projection aspect; `Viewport::pick` rebases the cursor to tab-local NDC. |
| Layout persistence across restarts | After settings sections are richer (Preferences-driven) |
| Inspector via reflection-keyed component editors | Stage 7 alongside the gizmos |
| Undo/redo for spawn/despawn/reparent | Stage 7 — needs stable entity ids |
| Console wired to a real log feed / Stage-10 terminal | Stage 10 |
[`Shell::default_dock`]: ../editor/src/shell.rs
[`Shell::try_consume_shortcut`]: ../editor/src/shell.rs
[`Shell::open_project`]: ../editor/src/shell.rs
[`SetTransformCmd`]: ../editor/src/commands.rs
[`SetTransformCmd::merge`]: ../editor/src/commands.rs
[`RenameCmd`]: ../editor/src/commands.rs
+154
View File
@@ -0,0 +1,154 @@
# File Watching
`oxide_engine::watch` watches directories on disk and emits **debounced**,
**deduplicated** change events. It is the Stage-6 foundation for the engine's
live-reload story:
- **Stage 6** — reload changed assets (via [`AssetServer`](assets.md)) so a
texture or model edited in an external tool reappears in the running editor
without restarting.
- **Stage 10** — recompile and hot-swap game scripts using the same event
stream and the same debounce logic.
- **Editor** — drives the Project panel's "files appeared / disappeared"
refresh.
The same module covers all of these because the hard part — "wait until the
filesystem stops twitching, then emit one event per path" — is identical in
every case.
## Why debounce
Filesystem events are noisy:
- Most editors save in several syscalls (write the file, rename a temp file
into place, chmod) — that is one logical change but several events.
- Recursive watches re-fire while a directory's children are being created.
- Backends collapse or split events differently across Linux, macOS, and
Windows.
If the engine reloaded on every raw event, one save could re-parse a model many
times over. The watcher gathers raw events into a **pending set** keyed by
path, then emits one event per path once that path has been **quiet** for a
configurable window.
## Architecture (two layers)
The module is intentionally split so most behavior is unit-testable without
touching real files.
### `Debouncer` — the pure core
A plain struct that takes `Instant`s from the caller. Tests drive it through a
deterministic timeline; no `sleep`, no flaky timing dependence on the OS event
queue.
```rust
use std::time::{Duration, Instant};
use oxide_engine::watch::{ChangeKind, Debouncer};
let mut d = Debouncer::new(Duration::from_millis(100));
let t0 = Instant::now();
d.record("assets/cube.gltf".into(), ChangeKind::Modified, t0);
d.record("assets/cube.gltf".into(), ChangeKind::Modified,
t0 + Duration::from_millis(20));
// Still hot — nothing fires.
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
// After 100 ms of quiet, one event fires for the path.
let ready = d.drain_ready(t0 + Duration::from_millis(130));
assert_eq!(ready.len(), 1);
```
Coalescing rules (chosen to match what a reloader downstream cares about):
| Earlier kind | Newer kind | Emitted kind |
|--------------|-----------|--------------|
| `Created` | `Modified` | `Created` |
| `Removed` | `Modified` | `Created` (file came back) |
| anything | `Removed` | `Removed` |
| anything else | newer | newer |
### `FileWatcher` — the real-world wrapper
Wraps a `notify::RecommendedWatcher` plus a worker thread that drives the
debouncer with real time and forwards settled events through an `mpsc` channel.
```rust,no_run
use std::time::Duration;
use oxide_engine::watch::FileWatcher;
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
watcher.watch("path/to/project/assets")?;
// In the editor's per-frame tick, drain whatever has settled:
while let Ok(event) = events.try_recv() {
println!("{:?} at {}", event.kind, event.path.display());
}
# Ok::<(), oxide_engine::watch::WatchError>(())
```
`FileWatcher` watches recursively. Dropping it stops the worker thread and
disconnects the receiver — no manual cleanup.
The quiet window is a knob: too short and you get repeated events from one
save; too long and the editor feels laggy. The default Stage-6 wiring uses
~150 ms.
## Asset reload
`reload_changed_assets` is the wiring between the watcher and the
[asset server](assets.md). For each `Created` or `Modified` event it calls
`AssetServer::reload_path`, which re-runs the loader for every cached asset at
that path and updates the existing handle **in place** — gameplay code holding
the handle sees the new contents on its next read.
```rust,no_run
use std::time::Duration;
use oxide_engine::asset::AssetServer;
use oxide_engine::watch::{reload_changed_assets, FileWatcher};
let assets = AssetServer::new();
let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
watcher.watch("path/to/project/assets")?;
// Per frame:
let batch: Vec<_> = std::iter::from_fn(|| events.try_recv().ok()).collect();
let reloaded = reload_changed_assets(&assets, batch);
if reloaded > 0 {
log::info!("hot-reloaded {} asset(s)", reloaded);
}
# Ok::<(), oxide_engine::watch::WatchError>(())
```
`AssetServer::reload_path` is type-erased on purpose. The cache records, per
entry, a function pointer that re-runs the loader for that entry's concrete
type, so the watcher can react to a disk change without knowing every asset
type at compile time. Paths that are not currently cached return zero work;
the next `load` picks up the fresh contents anyway. `Removed` events do **not**
invalidate cached handles — gameplay code may want the last-loaded copy to
keep working.
## What this groundwork enables
| Stage | Builds on |
|-------|-----------|
| 6 | Editor live-reload of assets; Project panel refresh |
| 7 | Watch input-binding config for changes during a session |
| 10 | Script hot-reload (same watcher; the reloader recompiles + swaps the module) |
| 11 | WGSL shader hot-reload |
## Testing strategy
- **Unit tests** drive `Debouncer` directly with fixed `Instant`s — fast,
deterministic, and they cover the coalescing rules exhaustively.
- **One tolerant smoke test** writes to a temp dir and polls for an event with
a generous deadline (seconds, not milliseconds). On containerized CI without
a usable event backend the test prints `SKIP:` and passes — the unit tests
already prove the logic is correct, this only checks the OS wiring is
connected.
[`watch`]: ../engine/src/watch.rs
[`Debouncer`]: ../engine/src/watch.rs
[`FileWatcher`]: ../engine/src/watch.rs
+113
View File
@@ -0,0 +1,113 @@
# Getting Started
This guide takes you from a clean machine to building Oxide, running an example,
and running the test and benchmark suites.
## Prerequisites
- **Rust stable toolchain.** Install via [rustup](https://rustup.rs):
```sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
Oxide tracks the stable channel and sets a minimum supported Rust version
(MSRV) in the workspace `Cargo.toml` (`rust-version`).
- **Linux** is the primary target. Other platforms are not yet tested.
- **A GPU with Vulkan support** (`wgpu` is integrated since Stage 2; on Linux
the Vulkan backend is the default). Backend selection can be overridden with
the `WGPU_BACKEND` environment variable.
## Cloning
```sh
git clone https://git.houmeres.sk/Houmeres/Oxide.git
cd Oxide
```
## Building
Build the whole workspace (engine, editor, examples, tests):
```sh
cargo build # debug
cargo build --release # optimized
```
The workspace is a Cargo workspace with these members:
- `oxide-engine` — the core library
- `oxide-editor` — the in-engine editor binary
- `oxide-examples` — runnable examples (one or more per stage)
- `oxide-tests` — the integration test harness
## Running the editor
```sh
cargo run -p oxide-editor --release
```
The editor opens its own window with a placeholder viewport (Stage 2); editor
panels arrive with the systems they edit in later stages. Quit with `Ctrl+Q`.
Raw input logging is visible with `RUST_LOG=debug`.
## Running examples
Examples live in `examples/src/bin/` and each is a standalone binary:
```sh
cargo run -p oxide-examples --bin math_demo
```
| Example | Stage | Description |
|---------|-------|-------------|
| `math_demo` | 1 | Prints a tour of transforms, bounds, ray/plane queries, frustum culling, color, and value ranges |
| `hello_window` | 2 | Opens a window cleared to a configurable color — keys `1``5` pick presets, `Space` cycles, `Esc` quits; FPS logged once per second |
## Running tests
```sh
cargo test # the whole workspace
cargo test -p oxide-engine # engine unit tests only
cargo test -p oxide-tests # integration tests only
```
Unit tests live next to the code they test (a `#[cfg(test)] mod tests` block in
each module). End-to-end and cross-cutting tests live in the `oxide-tests` crate.
## Running benchmarks
Performance-sensitive systems use [criterion](https://github.com/bheisler/criterion.rs):
```sh
cargo bench -p oxide-engine
```
Stage 1 ships the `transform` benchmark, which includes `compose_1m` (the Stage 1
budget is 1,000,000 transform compositions in under 10 ms).
## Lint and format
Both must be clean before any change is committed:
```sh
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
The engine and editor crates are compiled with `#![deny(warnings)]`, so warnings
are hard errors.
## Installing to the system (Linux)
```sh
chmod +x install.sh
./install.sh # installs to /usr/local
PREFIX=$HOME/.local ./install.sh # custom prefix
```
This builds in release mode and installs the `oxide-editor` binary plus assets.
To uninstall:
```sh
sudo rm /usr/local/bin/oxide-editor
sudo rm -rf /usr/local/share/oxide
```
+403
View File
@@ -0,0 +1,403 @@
# Input
Stage 7 reference for `oxide_engine::input` — the engine's input
abstraction. Three layers live here today:
- **Piece 1: [`InputState`](#raw-input-state)** — the raw per-frame
snapshot (keyboard / mouse / cursor / scroll, with edge detection).
- **Piece 2: [`Binding`] + [`ActionMap`](#named-actions-and-remapping)** —
named actions (e.g. `"Jump"`) bound to one or more physical inputs, with
defaults, runtime remapping, and RON-persistable user overrides.
- **Piece 3: [`AxisBinding`] + [`Axis2DBinding`](#directional-axes)** —
directional inputs composed from `Binding` direction sets (e.g. `WASD`
`Vec2 "Move"`), stored alongside button actions in the same `ActionMap`
and persisted through the same `ActionOverrides` payload.
The editor pieces (flythrough camera, bindings preferences page, transform
gizmos) layer on top of these.
For the raw [`WindowEvent`](windowing.md) vocabulary the runner pumps from,
see [windowing.md](windowing.md).
## Raw input state
### Why a separate layer
Game code wants three distinct things from a physical key:
- **The press edge** — fires *once* on the frame a key first goes down. A
jump fires here.
- **The release edge** — fires *once* on the frame a key comes back up. A
charged shot fires here.
- **The held state** — true every frame between press and release. A sprint
modifier reads this.
Reading these straight off `WindowEvent::KeyboardInput` is doable but error-
prone: OS key auto-repeat re-sends `Pressed` on every repeat, focus loss can
leave keys "held" with no matching release, and a `CursorMoved` carries no
delta unless the consumer remembers the previous position. `InputState`
solves all of that in one place, and its semantics are unit-tested.
## How the runner uses it
The windowing [`run`](windowing.md) loop owns one `InputState` and:
1. Pumps every incoming [`WindowEvent`](windowing.md) into it via
`InputState::handle_event` **before** any callback sees the event, so
`ctx.input()` in `WindowApp::event` already reflects the event being
delivered.
2. Calls `WindowApp::update` — game logic reads `ctx.input()` to query the
accumulated state for the frame.
3. After `update` returns, calls `InputState::end_frame` to roll edges and
per-frame deltas off. Held state and the cursor anchor persist.
The result: in `update`, edges describe what happened "since the previous
frame" and held state is "right now".
## Reading input from a `WindowApp`
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::event::MouseButton;
use oxide_engine::winit::keyboard::KeyCode;
#[derive(Default)]
struct MyApp;
impl WindowApp for MyApp {
fn update(&mut self, ctx: &mut AppCtx<'_>) {
let input = ctx.input();
if input.pressed(KeyCode::Space) {
// Fires once, on the frame Space went down.
}
if input.held(KeyCode::ShiftLeft) {
// True every frame Shift is down.
}
if input.released(KeyCode::Escape) {
ctx.request_exit();
}
// Right-drag pans by the mouse delta accumulated this frame.
if input.mouse_held(MouseButton::Right) {
let _delta = input.mouse_delta(); // physical pixels
}
// Scroll is in line-equivalent units (touchpad pixels are normalized
// so wheels and trackpads report on the same scale).
let _zoom_amount = input.scroll().y;
}
}
```
## Edge semantics, in detail
`InputState` keeps three sets per device (held / pressed / released) and
applies these rules:
- `press_key(k)` — if `k` was **not** already held, both `held` and
`pressed` add it. If it was already held (OS auto-repeat), `pressed` is
unchanged. The one-shot press edge fires exactly once per real keypress.
- `release_key(k)``held` removes `k`; `released` adds `k`. The release
edge fires whether or not the key was previously tracked as held, so the
occasional "release without matching press" the OS delivers (focus
changes, alt-tab) still produces a usable signal.
- `end_frame()` — clears `pressed` and `released` (and the per-frame mouse
delta + scroll). `held` and the cursor anchor are untouched.
- `WindowEvent::Focused(false)` — every currently-held key and mouse button
is force-released (released-edge fires for each), so a key held when the
user alt-tabbed away cannot remain stuck after the window comes back.
Mouse buttons mirror the keyboard rules exactly. Cursor + delta and scroll
use the same end-of-frame reset.
## Cursor and mouse delta
Cursor position is stored as physical pixels relative to the window. The
**delta** is the sum of the segment vectors between `set_cursor` calls
*within the frame*, not the gross displacement from the first event. The
first `set_cursor` after construction (or after `forget_cursor` /
`WindowEvent::CursorLeft`) seeds the anchor without contributing to the
delta — so the first frame the cursor enters the window never produces a
phantom jump.
```text
Frame 1: cursor enters at (100, 100) → delta = (0, 0)
Frame 2: moves (100,100)→(105,98)→(108,95) → delta = (8, -5)
Frame 3: no movement → delta = (0, 0)
(cursor still at (108, 95))
```
`add_mouse_delta(dx, dy)` exists for relative-motion sources that don't
go through `CursorMoved` (a future `DeviceEvent::MouseMotion` pump, a
pointer-lock toggle, or a synthesized test). It layers on top of the
cursor-based delta.
## Scroll
Scroll is reported in **line-equivalent units**: wheel notches arrive as
`LineDelta` and pass through unchanged; trackpad pixel deltas are divided
by a fixed pixels-per-line constant (40) so a touchpad gesture and a wheel
notch produce comparable numbers.
## Testing inputs directly
The mutator API (`press_key`, `release_mouse`, `set_cursor`,
`add_mouse_delta`, `add_scroll`, `forget_cursor`, `release_all_held`) is
the same path `handle_event` uses, and is intentionally public. Tests
should call it directly rather than try to fabricate `WindowEvent`s —
winit 0.30's `DeviceId` cannot be constructed outside a real event loop,
so most input variants are unreachable from synthesized events. The
mutators are unit-tested and exercised end-to-end by `stage7` integration
tests in the [`tests`](../tests) crate.
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::keyboard::KeyCode;
let mut input = InputState::new();
input.press_key(KeyCode::Space);
assert!(input.pressed(KeyCode::Space));
assert!(input.held(KeyCode::Space));
input.end_frame();
assert!(!input.pressed(KeyCode::Space));
assert!(input.held(KeyCode::Space));
```
## Named actions and remapping
`InputState` answers "is `KeyCode::Space` down?". Game code shouldn't ask
that question: physical keys are user-settings territory, and querying
them directly couples gameplay to a fixed keyboard layout. `ActionMap`
adds the indirection — game code asks "is `\"Jump\"` engaged?" and the
map resolves it to whatever the user (or the program's default) has
bound.
### The data model
An action carries two binding lists:
- **`defaults`** — the bindings registered from code at startup. They
never change at runtime.
- **`current`** — the bindings actually queried each frame. Initially a
clone of `defaults`; remapped by the settings screen; restored by the
"Restore defaults" button.
Persistence saves only `current`. On reload, the program first registers
actions from code (defaults reappear from source), then applies the saved
overrides on top. Actions that vanished from code never break an old
settings file — they're silently skipped.
### Setting up actions
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::event::MouseButton;
use oxide_engine::winit::keyboard::KeyCode;
let mut actions = ActionMap::new();
actions
.register("Jump", [Binding::Key(KeyCode::Space)])
.register(
"Sprint",
[
Binding::Key(KeyCode::ShiftLeft),
Binding::Key(KeyCode::ShiftRight),
],
)
.register("Fire", [Binding::Mouse(MouseButton::Left)]);
```
Multi-bind on either axis is supported: an action can list several
bindings (the `Sprint` example), and one physical key can drive several
actions (e.g. `Space` → both `"Jump"` and `"Confirm"`).
### Querying actions
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::winit::keyboard::KeyCode;
# let mut actions = ActionMap::new();
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
# let input = InputState::new();
if actions.action_pressed("Jump", &input) {
// Fires once, on the frame Jump becomes engaged.
}
if actions.action_held("Jump", &input) {
// True every frame Jump is engaged (at least one binding held).
}
if actions.action_released("Jump", &input) {
// Fires once, when the last engaged binding releases.
}
```
Action edges have **hysteresis at the action level**, not the binding
level: pressing a second binding while the action is already engaged does
not retrigger `action_pressed`, and releasing one binding while another
is still held does not fire `action_released`. The edge fires only on
the action's transition between engaged and disengaged. (See the
[`action_pressed`](../engine/src/input/action.rs) rustdoc for the
precise definition.)
Querying an unregistered action returns `false` everywhere — never a
panic — so typo'd action names are graceful.
### Runtime remap
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::winit::keyboard::KeyCode;
# let mut actions = ActionMap::new();
# actions.register("Jump", [Binding::Key(KeyCode::Space)]);
// A bindings preferences page calls these — game code is untouched.
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
actions.add_binding("Jump", Binding::Key(KeyCode::Space)); // restore as alt
actions.remove_binding("Jump", Binding::Key(KeyCode::KeyW));
actions.clear_bindings("Jump"); // make Jump temporarily unbindable
actions.restore_defaults("Jump"); // ↩ user's defaults
actions.restore_all_defaults(); // ↩ everything
```
### Persistence via the Stage-6 settings framework
`ActionOverrides` is the serializable projection of an `ActionMap`'s
current bindings, and it derives `Default + Serialize + Deserialize` so
it plugs straight into `Settings::register::<ActionOverrides>(name)`
no framework code changes needed. The whole cycle:
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::keyboard::KeyCode;
// One-time setup at startup.
let mut actions = ActionMap::new();
actions.register("Jump", [Binding::Key(KeyCode::Space)]);
let mut settings = Settings::new();
settings.register::<ActionOverrides>("input.bindings");
// User remap → write into Settings → export to disk.
actions.set_bindings("Jump", vec![Binding::Key(KeyCode::KeyW)]);
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
actions.overrides();
let on_disk = settings.export(); // RON map, persist however you like
// On the next launch, after re-registering defaults from code:
settings.import(&on_disk);
actions.apply_overrides(settings.get::<ActionOverrides>("input.bindings").unwrap());
// Jump is now bound to W again.
```
The `apply_overrides` step is order-independent with respect to which
actions the file knows about: unknown names are skipped, and registered
actions absent from the file keep their defaults.
## Directional axes
Buttons answer "is this engaged?". Movement and camera control want a
**direction with magnitude**. `AxisBinding` (1D, returns `f32`) and
`Axis2DBinding` (2D, returns `Vec2`) compose direction sets of
`Binding`s into those values. They live in the same [`ActionMap`] as
button actions but in **separate name spaces**, so `"Move"` can be a 2D
axis and `"MoveSlower"` a button without conflict — and a `"Move"`
button can coexist with a `"Move"` axis if a project wants it to.
### 1D axes
An `AxisBinding` is a pair of binding sets (one for the +1 direction,
one for 1). Any binding held on a side contributes a full unit; if
both sides are held simultaneously they cancel to 0 — a "soft brake"
the player gets for free.
```rust
use oxide_engine::prelude::*;
use oxide_engine::winit::keyboard::KeyCode;
let mut actions = ActionMap::new();
actions.register_axis(
"MoveX",
AxisBinding::new(
[Binding::Key(KeyCode::KeyD)],
[Binding::Key(KeyCode::KeyA)],
),
);
let mut input = InputState::new();
input.press_key(KeyCode::KeyD);
assert_eq!(actions.axis("MoveX", &input), 1.0);
```
Multiple bindings on the same direction do **not** stack (`D` and `→`
both held still reads as `1.0`, not `2.0`) — the axis reports
direction, not accumulated input.
### 2D axes
`Axis2DBinding::new(right, left, up, down)` composes four direction
sets into a `Vec2`. Diagonals are intentionally **not normalized** — a
game that wants unit-length movement normalizes at the call site; a
game that wants diagonal-faster gets it for free. The cancel-on-both
rule applies independently on each axis.
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::winit::keyboard::KeyCode;
let mut actions = ActionMap::new();
actions.register_axis_2d(
"Move",
Axis2DBinding::new(
[Binding::Key(KeyCode::KeyD)],
[Binding::Key(KeyCode::KeyA)],
[Binding::Key(KeyCode::KeyW)],
[Binding::Key(KeyCode::KeyS)],
),
);
let mut input = InputState::new();
input.press_key(KeyCode::KeyW);
input.press_key(KeyCode::KeyA);
let v = actions.axis_2d("Move", &input);
assert_eq!(v, oxide_engine::math::Vec2::new(-1.0, 1.0));
// If you want unit-length: `if v != Vec2::ZERO { v.normalize() } else { v }`
```
### Remap, restore, and persistence
`set_axis_bindings` / `set_axis_2d_bindings` swap the current bindings
without renaming the action. `restore_axis_defaults` /
`restore_axis_2d_defaults` revert to the code-defined bindings.
`restore_all_defaults` covers every action across all three kinds in
one call.
[`ActionOverrides`] carries axis overrides alongside button overrides
in three sub-maps. The settings round-trip is identical to the button
case — `ActionOverrides` is the same settings-section type:
```rust
# use oxide_engine::prelude::*;
# let mut actions = ActionMap::new();
# let mut settings = Settings::new();
settings.register::<ActionOverrides>("input.bindings");
*settings.get_mut::<ActionOverrides>("input.bindings").unwrap() =
actions.overrides();
let on_disk = settings.export(); // axes, axes_2d, and buttons all persist
```
Older settings files written before axes existed (i.e. with no `axes` or
`axes_2d` field in the RON) load cleanly — the missing sub-maps
deserialize as empty, and registered axes keep their code-defined
defaults.
## Status and what's next
- **Piece 1 (✅).** Raw `InputState` + runner integration.
- **Piece 2 (✅).** Named button action mapping with defaults, multi-bind
in either direction, runtime remap, and RON persistence through the
Stage-6 settings framework.
- **Piece 3 (✅ — this section).** 1D and 2D directional axes composed
from `Binding` direction sets, sharing `ActionMap` storage and the
same `ActionOverrides` persistence payload.
- **Editor pieces (planned).** Flythrough camera using the action map,
bindings page in Preferences contributed via the Stage-6 extension API,
transform gizmos.
+155
View File
@@ -0,0 +1,155 @@
# Layers, Groups & Tags
`oxide_engine::layer` is the engine's shared answer to *"which things interact
with which?"*. Instead of every subsystem inventing its own notion of
collision groups, render masks, or query filters, they all reference one
primitive — the [`LayerMask`] — so a layer named once is honored everywhere.
This system lands in Stage 5 (Engine Core Framework). The consumers below are
wired up in their own stages, but they all build on the types here.
## The model: one Layer, many Groups
Oxide follows the **Unity model**, separating two concepts:
- **[`Layer`]** — *single-valued* membership. Every entity is on exactly **one**
of 32 logical layers (its index `0..32`). This is the fast filter slot used by
rendering, physics, and queries.
- **[`Tags`]** — *multi-valued* gameplay grouping. An entity can be in **any
number** of named groups (`"Enemies"`, `"Interactables"`, `"SaveOnExit"`) that
game code and scripts look up by name.
So: *what kind of thing is this, for fast filtering?* → one **Layer**. *What
gameplay categories does it belong to?* → many **Groups** (tags). The two never
fight over the same slot.
## `LayerMask`: the filter primitive
A `LayerMask` is a 32-slot bitset packed into a `u32`. An entity's `Layer` is a
single index, but the things that *select* entities carry a **mask** — a
camera's visibility, a physics collision filter, a raycast's query mask — so a
filter can target several layers at once. An entity is selected when its layer
is one of the bits in the filter:
```rust
use oxide_engine::layer::{Layer, LayerMask};
let entity = Layer::on(2); // membership: layer 2
let filter = LayerMask::NONE.with(1).with(2); // a filter selecting 1 or 2
assert!(entity.matches(filter)); // layer 2 is in the mask → selected
```
`a.intersects(b)` (equivalently `(a & b) != 0`) is the universal mask
interaction test. Everything else (`with`/`without`/`union`/`intersection`/
`complement`, the `|`/`&`/`^`/`!` operators, `iter`) is convenience around that.
Layer indices run `0..32`. Passing an index `>= 32` panics in every build — a
mistake worth catching loudly rather than wrapping silently.
`Layer` is **node-baked**: `Scene::spawn` auto-attaches `Layer::DEFAULT` (index
0) to every entity, so a fresh entity is visible to broad "see everything"
filters out of the box.
## Naming layers: `LayerRegistry`
Bits are not self-documenting, so a project keeps a [`LayerRegistry`] mapping
indices to names. Index 0 is seeded as `"Default"`; the rest are unnamed until
assigned. The registry is project-level data (serialized with the project in a
later stage), and renaming a layer never moves an entity — only the label
changes.
```rust
use oxide_engine::layer::LayerRegistry;
let mut registry = LayerRegistry::new();
registry.set(1, "Player");
registry.set(2, "NPC");
// Author a filter by name instead of by magic number:
let visible_to_camera = registry.mask_of(["Player", "NPC"]);
assert_eq!(registry.index_of("Player"), Some(1));
```
The editor seeds a small, generally-useful starter set — `Default`, `UI`,
`Player`, `World` — and exposes the registry through its **Layer Names** editor
(opened from the inspector's `Layer` dropdown). Because both a camera's
visibility mask and a raycast's filter can be built from the same names, the
*named layer* is the single source of truth.
## Gameplay grouping: `Tags` + `GroupRegistry`
[`Tags`] is the per-entity, string-keyed set that holds an entity's **group**
membership — the multi-valued counterpart to its single `Layer`:
```rust
use oxide_engine::layer::Tags;
let mut tags = Tags::single("Enemy");
tags.insert("Flying");
assert!(tags.contains("Enemy"));
```
The set of *valid* group names is project-level data in a [`GroupRegistry`], so
the editor offers a fixed vocabulary to pick from (predefined, like layers)
rather than free-typed strings. Defining or deleting a group only changes that
vocabulary — it never touches the tags already on entities:
```rust
use oxide_engine::layer::GroupRegistry;
let mut groups = GroupRegistry::new();
groups.define("Enemies");
groups.define("Interactables");
assert!(groups.contains("Enemies"));
```
In the editor, the inspector's **Groups** dropdown is a multi-select of the
defined groups (each a checkbox toggling membership in the entity's `Tags`), with
an **Edit groups…** entry opening the Groups editor to manage the vocabulary.
Use a **layer** when something must filter quickly and en masse (rendering,
physics, queries). Use a **group** when you need to ask "what gameplay
categories is this in?" and an entity may be in several at once.
## Attaching to entities
`Layer` is auto-attached; `Tags` is an ordinary ECS component — attach it through
the scene's world and query it like anything else:
```rust
use oxide_engine::prelude::*;
use oxide_engine::layer::{LayerMask, Layer, Tags};
let mut scene = Scene::new();
let guard = scene.spawn("guard", Transform::IDENTITY); // Layer::DEFAULT auto-attached
scene.world_mut().insert_one(guard, Layer::on(2)).unwrap(); // move to layer 2
scene.world_mut().insert_one(guard, Tags::single("Enemy")).unwrap();
// Find everything a layer-2 query would hit:
let filter = LayerMask::layer(2);
for (entity, layer) in scene.world().query::<&Layer>().iter() {
if layer.matches(filter) {
// ... this entity is selected
}
}
```
All five types (`LayerMask`, `LayerRegistry`, `Layer`, `Tags`, `GroupRegistry`)
are `serde`-serializable, so layer/group data round-trips through RON and is
dual-editable from the editor and from scripts/AI agents like every other engine
component.
## Who consumes this
| Consumer | Stage | How it uses layers |
|----------|-------|--------------------|
| Render pass pipeline | 5 | A camera holds a visibility `LayerMask`; only entities whose `Layer` is in it are drawn |
| Physics | 9 | A collider's membership + filter masks drive collision groups and sensor/trigger filtering |
| Scene queries | 9 | A raycast/shape-cast carries a filter mask tested against candidates' layer |
| Editor | 6+ | Single-select `Layer` dropdown + Layer Names editor; multi-select `Groups` dropdown + Groups editor |
[`LayerMask`]: ../engine/src/layer/mask.rs
[`LayerRegistry`]: ../engine/src/layer/registry.rs
[`Layer`]: ../engine/src/layer/components.rs
[`Tags`]: ../engine/src/layer/components.rs
[`GroupRegistry`]: ../engine/src/layer/groups.rs
+353
View File
@@ -0,0 +1,353 @@
# Math & Core Primitives
The `oxide_engine::math` module is the foundation every other system depends on.
It builds on [`glam`](https://docs.rs/glam) for vectors, quaternions, and
matrices, and adds the engine's higher-level geometric and utility types.
This document is the usage reference for the module as delivered in **Stage 1**.
For the conventions these types follow (handedness, units, color space), see
[conventions.md](conventions.md).
## Importing
Everything is available through the module path or the prelude:
```rust
use oxide_engine::math::{Transform, Aabb, Ray, Plane, Frustum, Color, Rect, Range3};
// or, more commonly:
use oxide_engine::prelude::*;
```
The prelude also re-exports the `glam` types you need for everyday work — `Vec2`,
`Vec3`, `Vec4`, `Quat`, `Mat3`, `Mat4`, and `EulerRot` — so downstream crates
need no direct `glam` dependency.
## Contents
| Type | Role |
|------|------|
| [`Transform`](#transform) | Placement: translation + rotation + scale |
| [`Aabb`](#aabb) | Axis-aligned bounding box (geometry/bounds/culling) |
| [`Ray`](#ray) | Origin + normalized direction (picking, queries) |
| [`Plane`](#plane) | Infinite plane in Hessian normal form |
| [`Frustum`](#frustum) | Six-plane view volume for visibility culling |
| [`Color`](#color) | Linear RGBA color with sRGB conversion |
| [`Rect`](#rect) | 2D rectangle (UI, viewports, texture regions) |
| [`Range3`](#range3) | 3D value range (clamp, lerp, remap) |
All types are `Copy`, `PartialEq`, and `serde`-(de)serializable.
---
## Transform
A 3D affine transform stored in **decomposed** form — `translation` (`Vec3`),
`rotation` (`Quat`), and `scale` (`Vec3`) — so each channel stays editable
without matrix round-trips. The effective matrix is `T * R * S`.
### Construction
```rust
use oxide_engine::prelude::*;
let a = Transform::IDENTITY;
let b = Transform::from_translation(Vec3::new(0.0, 1.0, 0.0));
let c = Transform::from_rotation(Quat::from_rotation_y(90_f32.to_radians()));
let d = Transform::from_scale(Vec3::splat(2.0));
let e = Transform::from_trs(
Vec3::new(1.0, 2.0, 3.0),
Quat::from_euler(EulerRot::XYZ, 0.1, 0.2, 0.3),
Vec3::splat(1.0),
);
// From / to a 4x4 matrix:
let m = e.to_matrix(); // glam::Mat4
let back = Transform::from_matrix(m);
let affine = e.to_affine(); // glam::Affine3A (cheaper to compose)
```
### Composition and hierarchy
`mul_transform` composes parent-first, so this is how you resolve a child into
its parent's space:
```rust
let parent = Transform::from_trs(
Vec3::new(10.0, 0.0, 0.0),
Quat::from_rotation_y(90_f32.to_radians()),
Vec3::splat(2.0),
);
let child_local = Transform::from_translation(Vec3::new(0.0, 0.0, 1.0));
let child_world = parent.mul_transform(&child_local);
// child_world.translation == (12, 0, 0)
```
Composition is exact for uniform scale and a closest-fit approximation for
non-uniform scale combined with rotation (see
[conventions](conventions.md#transform-composition)).
### Applying a transform
```rust
let t = Transform::from_trs(
Vec3::new(1.0, 0.0, 0.0),
Quat::from_rotation_z(90_f32.to_radians()),
Vec3::splat(2.0),
);
let p = t.transform_point(Vec3::new(1.0, 0.0, 0.0)); // affected by T, R, S
let v = t.transform_vector(Vec3::new(1.0, 0.0, 0.0)); // R and S only (no translation)
```
### Inverse
`inverse()` returns a transform that undoes this one. It is exact for uniform
scale; with any zero scale component the transform is not invertible and the
inverse scale will contain infinities (check with `is_finite()`).
```rust
let undo = t.inverse();
let identity = t.mul_transform(&undo); // ≈ Transform::IDENTITY
```
### Direction and orientation helpers
```rust
let dir_forward = t.forward(); // local -Z
let dir_up = t.up(); // local +Y
let dir_right = t.right(); // local +X
// Build an orientation that looks from `eye` toward `target`:
let cam = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
// cam.forward() points at the target. Degenerate (eye == target) → identity rotation.
```
### Edge cases handled
- **Zero scale** → non-invertible; the forward transform is still finite.
- **Gimbal-lock orientations** (e.g. ±90° pitch) round-trip through a matrix
without losing orthonormality of the basis vectors.
- **Degenerate `looking_at`** (eye == target) returns identity rotation rather
than producing NaNs.
---
## Aabb
An axis-aligned bounding box defined by `min` and `max` corners. Used for bounds,
broad-phase overlap, and frustum culling. A box is *empty* when any `min`
component exceeds its `max`; `Aabb::EMPTY` is the identity for `union`.
```rust
use oxide_engine::prelude::*;
let a = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0)); // corners auto-sorted
let b = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
let c = Aabb::from_points([Vec3::ZERO, Vec3::new(2.0, -1.0, 4.0), Vec3::new(-3.0, 5.0, 1.0)]);
// Queries:
let center = b.center();
let size = b.size();
let inside = b.contains_point(Vec3::ZERO);
let near = b.closest_point(Vec3::new(5.0, 0.0, 0.0));
// Set operations:
let u = a.union(&b);
let i = a.intersection(&b); // Aabb::EMPTY if disjoint
let hit = a.intersects(&b); // bool (touching counts)
// Acceleration-structure metrics:
let area = b.surface_area(); // for SAH
let vol = b.volume();
let pts = b.corners(); // [Vec3; 8]
// Ray test (slab method): returns entry distance t, or None.
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
if let Some(t) = b.ray_intersection(&ray) {
let point = ray.at(t);
}
```
A ray whose origin is inside the box returns `Some(0.0)`.
---
## Ray
A half-line with an `origin` and a **normalized** `direction`. Because the
direction is unit-length, the parameter `t` in `at(t)` is a true distance.
```rust
use oxide_engine::prelude::*;
let r = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0)); // direction normalized to +Y
let r2 = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
let p = r.at(4.0); // origin + direction * 4
let valid = r.is_valid(); // false if direction was zero-length
let near = r.closest_point(target); // clamped to t >= 0
let dist = r.distance_to_point(target);
```
If you pass a zero-length direction, the ray is left degenerate; check
`is_valid()` before relying on it.
---
## Plane
An infinite plane in Hessian normal form: a unit `normal` and a signed distance
`d`, such that every point on the plane satisfies `normal·p + d = 0`. The
positive half-space is the side the normal points toward.
```rust
use oxide_engine::prelude::*;
let p1 = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
let p2 = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y); // normal via right-hand rule → +Z
let p3 = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0); // normalized on construction
let sd = p1.signed_distance(Vec3::new(0.0, 5.0, 0.0)); // +3.0 (in front)
let proj = p1.project_point(Vec3::new(3.0, 7.0, -2.0)); // orthogonal projection onto plane
let flipped = p1.flipped(); // same plane, reversed normal
// Ray test: None if parallel or pointing away.
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
if let Some(t) = p1.ray_intersection(&ray) {
let hit = ray.at(t);
}
```
---
## Frustum
A view volume represented by six planes (left, right, bottom, top, near, far),
each with its normal pointing **inward**. A point is inside when it lies in the
positive half-space of every plane. Built from a combined view-projection matrix
via the GribbHartmann method; works for perspective and orthographic
projections alike.
```rust
use oxide_engine::prelude::*;
let proj = Mat4::perspective_rh(60_f32.to_radians(), 16.0 / 9.0, 0.1, 100.0);
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
let frustum = Frustum::from_view_projection(proj * view);
let visible_point = frustum.contains_point(Vec3::ZERO);
let bb = Aabb::from_center_half_extents(Vec3::ZERO, Vec3::splat(1.0));
let visible_box = frustum.intersects_aabb(&bb);
let visible_sphere = frustum.intersects_sphere(Vec3::ZERO, 1.0);
```
`intersects_aabb` is **conservative**: it never culls a box that is actually
visible, though it may very rarely keep one that is just outside a corner. That
is the correct trade-off for rendering, where false positives cost a wasted draw
but false negatives cause visible pop-out.
---
## Color
Linear RGBA color with `f32` channels. The engine works in **linear** space;
conversions to/from 8-bit sRGB are explicit. Values may exceed `1.0` to represent
HDR/emissive intensity and are not clamped in storage.
```rust
use oxide_engine::prelude::*;
let white = Color::WHITE;
let custom = Color::rgba(0.2, 0.4, 0.6, 1.0);
let from_pdf = Color::from_srgb_u8(135, 206, 235); // sRGB bytes → linear
let from_hex = Color::from_hex(0x87CEEB); // #87CEEB → linear
let bytes = custom.to_srgb_u8(); // [u8; 4] sRGB, clamped to [0,1]
let v4 = custom.to_vec4(); // Vec4 [r,g,b,a]
let v3 = custom.to_vec3(); // Vec3 rgb
let faded = custom.with_alpha(0.5);
let mid = Color::BLACK.lerp(Color::WHITE, 0.5); // t clamped to [0,1]
```
Constants: `BLACK`, `WHITE`, `RED`, `GREEN`, `BLUE`, `TRANSPARENT`.
---
## Rect
A 2D axis-aligned rectangle for UI, viewports, and texture regions.
```rust
use oxide_engine::prelude::*;
let r1 = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0)); // corners auto-sorted
let r2 = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
let r3 = Rect::from_center_size(Vec2::ZERO, Vec2::splat(2.0));
let (w, h) = (r2.width(), r2.height());
let size = r2.size();
let area = r2.area();
let c = r2.center();
let empty = Rect::ZERO.is_empty();
let inside = r2.contains_point(Vec2::ONE);
let hit = r2.intersects(&r3);
let i = r2.intersection(&r3); // Rect::ZERO if disjoint
let u = r2.union(&r3);
let near = r2.closest_point(Vec2::new(5.0, -1.0));
let grown = r2.expanded(1.0); // negative shrinks
```
---
## Range3
A 3D **value** range — an inclusive `[min, max]` interval per axis. Unlike
`Aabb` (which models geometry), `Range3` models a *value range* for clamping,
interpolation, and remapping. It deliberately provides `lerp`/`inverse_lerp`/
`remap`, which an `Aabb` does not.
```rust
use oxide_engine::prelude::*;
let r = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
let unit = Range3::UNIT; // [0, 1] per axis
let sym = Range3::symmetric(Vec3::splat(2.0)); // [-2, 2] per axis
let span = r.span();
let center = r.center();
let clamped = r.clamp(Vec3::new(-5.0, 50.0, 200.0)); // → (0, 50, 100)
let inside = r.contains(Vec3::splat(50.0));
let v = r.lerp(Vec3::splat(0.25)); // NOT clamped — extrapolates outside [0,1]
let t = r.inverse_lerp(Vec3::splat(25.0)); // → 0.25 per axis (0.0 on zero-span axes)
let remapped = r.remap(Vec3::splat(50.0), &unit); // 50 in [0,100] → 0.5 in [0,1]
```
`inverse_lerp` guards against division by zero: an axis with zero span yields
`0.0` rather than `NaN`/`inf`.
---
## Testing and performance
- **Unit tests** for every type live in that type's source file
(`engine/src/math/*.rs`), covering core behavior and edge cases (zero scale,
gimbal lock, degenerate rays/planes, empty boxes, zero-span ranges).
- **Integration / fuzz tests** live in the `oxide-tests` crate
(`stage1::fuzz_transform_chains_stay_stable` builds long random transform
chains and verifies stability and inverse round-trips).
- **Benchmark:** `cargo bench -p oxide-engine` runs the `transform` benchmark.
The Stage 1 budget — 1,000,000 transform compositions in under 10 ms — is met
with margin (~5 ms on a typical desktop).
## Runnable example
```sh
cargo run -p oxide-examples --bin math_demo
```
`examples/src/bin/math_demo.rs` exercises every type above and prints the
results — a good place to see the API in use end to end.
+160
View File
@@ -0,0 +1,160 @@
# App, Modules & Scheduling
`oxide_engine::app` is where the Stage-5 core framework comes together. An
[`App`] owns the shared engine state and a [`Schedule`] of systems; functionality
is added by **modules**. This is the spine the rest of the engine plugs into: the
engine is *composed* rather than hard-wired, and an exported game compiles in
only the modules it registers.
## The App
An `App` owns:
- the active [`Scene`](scene.md),
- the shared [`AssetServer`](assets.md),
- the [`TypeRegistry`](reflection.md) (dual-editable components),
- the project's [`LayerRegistry`](layers.md),
- frame [`Time`], and
- arbitrary user **resources** (a type-keyed store).
```rust
use oxide_engine::app::{App, DefaultModules};
let mut app = App::new();
app.add_modules(DefaultModules);
app.update(1.0 / 60.0); // advance one frame
```
> Note: the application core is `oxide_engine::app::App`. It is intentionally
> *not* in the prelude, to avoid clashing with the windowing
> [`App`](windowing.md) trait (the per-window event handler). Import it directly.
### Resources
Resources are shared singletons addressed by type — the home for state that
isn't per-entity (an input map, a physics world, game settings):
```rust
# use oxide_engine::app::App;
# let mut app = App::new();
app.insert_resource(0u32);
*app.get_resource_mut::<u32>().unwrap() += 1;
assert_eq!(app.get_resource::<u32>(), Some(&1));
```
## Systems and the schedule
A **system** is any `FnMut(&mut App)` attached to a [`Schedule`] phase. Phases run
in a fixed order each frame; within a phase, systems run in registration order,
so behavior is fully deterministic.
| Phase | Purpose |
|-------|---------|
| `First` | start-of-frame bookkeeping |
| `Input` | gather input (Stage 7) |
| `PreUpdate` | engine work before game logic |
| `FixedUpdate` | fixed-timestep work; runs **0..n** times per frame (physics, Stage 9) |
| `Update` | per-frame game logic |
| `PostUpdate` | engine work after game logic |
| `Render` | drawing (Stage 5 pipeline onward) |
| `Last` | end-of-frame cleanup |
```rust
use oxide_engine::app::{App, Schedule};
use oxide_engine::prelude::*;
let mut app = App::new();
app.scene.spawn("spinner", Transform::IDENTITY);
app.add_system(Schedule::Update, |app| {
let dt = app.time.delta;
for e in app.scene.entities().collect::<Vec<_>>() {
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
t.translation.x += dt;
}
}
});
```
Systems get exclusive `&mut App` while running (the schedule is moved out of the
app for the duration), so a system can freely read and mutate the scene,
resources, and assets.
### Fixed timestep
`FixedUpdate` is driven by an accumulator so simulation is frame-rate
independent: each `update(dt)` runs as many whole `fixed_delta` steps as the
accumulated time allows. The number of steps per frame is **capped** so a long
stall (a breakpoint, a hitch) cannot trigger an unbounded catch-up "spiral of
death". Set the rate with `app.set_fixed_timestep(seconds)` (default 1/60).
The whole frame's scheduling overhead is a few dozen nanoseconds even with
several systems registered (see `cargo bench -p oxide-engine --bench app`), so it
is lost in the noise next to any real per-frame work.
## Modules
A [`Module`] is the unit of engine extension. Its [`build`](Module::build) method
registers systems, component types, asset loaders, and resources through the
`App` facade. Everything registered during `build` is **attributed to the
module**, so it can be enabled, disabled, or removed as one unit.
```rust
use oxide_engine::app::{App, Module, Schedule};
struct HeartbeatModule;
impl Module for HeartbeatModule {
fn name(&self) -> &'static str { "heartbeat" }
fn build(&self, app: &mut App) {
app.insert_resource(0u64);
app.add_system(Schedule::Update, |app| {
*app.get_resource_mut::<u64>().unwrap() += 1;
});
}
}
let mut app = App::new();
app.add_module(HeartbeatModule);
```
### Enable / disable / remove
These power the editor's module management (Stage 6) and the "ship only what you
use" principle:
```rust
# use oxide_engine::app::{App, Module, Schedule};
# struct HeartbeatModule;
# impl Module for HeartbeatModule {
# fn name(&self) -> &'static str { "heartbeat" }
# fn build(&self, app: &mut App) {}
# }
# let mut app = App::new();
# app.add_module(HeartbeatModule);
app.set_module_enabled("heartbeat", false); // systems skipped, nothing removed
app.set_module_enabled("heartbeat", true); // resumes
app.remove_module("heartbeat"); // systems, types, loaders, resources gone
```
`remove_module` undoes every registration the module made — its systems,
reflected types, asset loaders, and resources — leaving no dangling references.
Disabling is the cheap, reversible version (systems are skipped but kept).
### Built-in modules
`DefaultModules` bundles the engine's standard set:
- **`core`** — registers the always-present scene component types (`Transform`,
`Node`, `Layer`, `Tags`) for reflection, exposing them to the editor and
scripts.
- **`render`** — registers the renderable `MeshRenderer` component and (as the
data-driven render pipeline grows) the render-phase systems.
Subsystems from Stage 9 on (physics, audio, …) are built as their own
feature-gated crates, each exposing a `Module`, so a project pays for them only
by registering them.
[`App`]: ../engine/src/app/mod.rs
[`Time`]: ../engine/src/app/mod.rs
[`Schedule`]: ../engine/src/app/schedule.rs
[`Module`]: ../engine/src/app/module.rs
[`Scene`]: ../engine/src/scene/graph.rs
+299
View File
@@ -0,0 +1,299 @@
# Physics (`oxide-physics`) — Stage 9
Oxide's physics is a **feature-gated module** (`oxide-physics`) built on
[`rapier3d`](https://rapier.rs), added to an app through the Stage-5 module
system. It is a comprehensive rigid-body system — collision, scene queries,
joints, and a kinematic character controller — not a thin wrapper. This document
grows piece by piece as Stage 9 lands; it currently covers the **data model and
module wiring** (piece 1).
## Design: the ECS is the source of truth
A physics object is described by two plain, serializable, reflected components on
a scene entity:
- [`RigidBody`](#rigidbody) — *how* it moves (or that it doesn't).
- [`Collider`](#collider) — *what shape* it is, its material, and the
[`LayerMask`](layers.md) filtering of what it collides with.
The rapier simulation world is a **transient resource rebuilt from these
components**, never the authoritative store. That has a deliberate payoff: the
Stage-8.7 [play-mode snapshot](play-mode.md) captures these components like any
other, so **Play** runs the simulation, **Stop** reverts the authored components,
and the next **Play** rebuilds the rapier world fresh — with no special-casing.
The [`Transform`](scene.md) is the authoritative pose; the simulation writes it
back each fixed step (a later piece).
## Adding physics to an app
```rust
use oxide_engine::app::{App, DefaultModules};
use oxide_physics::PhysicsModule;
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule); // registers RigidBody/Collider + PhysicsSettings
```
`PhysicsModule` registers the component types for reflection (so they are
dual-editable from the inspector and from scripts/RON, and captured by the play
snapshot) and inserts a [`PhysicsSettings`] resource holding the global gravity
vector. Like every module it can be enabled, disabled, or removed as a unit, so a
game that never uses physics never compiles it in.
## RigidBody
The dynamics half of a physics object — attach alongside a `Collider`.
| Field | Meaning |
|-------|---------|
| `kind` | `Dynamic` (simulated), `Kinematic` (game-moved, unaffected by forces), or `Static` (immovable world geometry) |
| `mass` | Mass in kg; `0` derives it from the collider's `density` |
| `linear_damping` / `angular_damping` | Velocity drag (`0` = none) |
| `gravity_scale` | Per-body gravity multiplier (`1` normal, `0` floats) |
| `ccd` | Continuous collision detection for fast bodies (off by default) |
```rust
use oxide_physics::{RigidBody, RigidBodyKind};
let dynamic = RigidBody::default(); // a fully-simulated body
let floor = RigidBody::static_body(); // immovable
let platform = RigidBody::kinematic(); // moved by the game
```
## Collider
The shape + material half — attach on its own for static geometry, or with a
`RigidBody` for a moving body. The shape is a flat selector plus dimension fields
(mirroring `MeshRenderer`'s `PrimitiveShape`), so the inspector renders a clean
combo + drag-values:
| `shape` | Dimensions used |
|---------|-----------------|
| `Box` | `half_extents` (per-axis half sizes) |
| `Sphere` | `radius` |
| `Capsule` | `radius` + `half_height` (axis = local `+Y`) |
| `Cylinder` | `radius` + `half_height` (axis = local `+Y`) |
Material/filter fields: `friction`, `restitution`, `density`, `sensor` (a trigger
that reports overlap without resolving contact), and the `membership` / `filter`
[`LayerMask`](layers.md)s. Two colliders interact only when each one's
`membership` intersects the other's `filter`, so collision groups, triggers, and
(later) scene queries all use the engine's one shared filtering primitive.
```rust
use oxide_engine::math::Vec3;
use oxide_engine::layer::LayerMask;
use oxide_physics::Collider;
let ground = Collider::cuboid(Vec3::new(10.0, 0.5, 10.0));
let ball = Collider::ball(0.5);
let trigger = Collider::cuboid(Vec3::splat(1.0)).as_sensor()
.with_layers(LayerMask::layer(0), LayerMask::layer(1)); // only fires for layer 1
```
Convex-hull and triangle-mesh colliders (which need mesh data) are a later
Stage-9 piece.
## Simulation
`PhysicsModule` installs a [`PhysicsWorld`] resource — the rapier simulation plus
an entity ↔ body map — and a `FixedUpdate` system that, each fixed step:
1. **syncs** the rapier world to the scene (inserts a body+collider for each new
physics entity, removes bodies for despawned ones, pushes kinematic targets),
2. **steps** rapier by one [`fixed_delta`](modules.md), then
3. **writes back** each moved body's pose onto its entity's `Transform`.
So physics runs whenever the app advances its fixed timestep — including the
editor's **Play** mode, which is the first real consumer (Play drops a body,
Stop reverts it). Nothing extra is wired: the play snapshot already captures the
components.
```rust
# use oxide_engine::app::{App, DefaultModules};
# use oxide_engine::math::{Transform, Vec3};
# use oxide_physics::{PhysicsModule, RigidBody, Collider};
let mut app = App::new();
app.add_modules(DefaultModules);
app.add_module(PhysicsModule);
let ball = app.scene.spawn("ball", Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)));
app.scene.world_mut().insert_one(ball, RigidBody::default()).unwrap();
app.scene.world_mut().insert_one(ball, Collider::ball(0.5)).unwrap();
for _ in 0..120 { app.step(); } // one fixed tick each
// the ball has fallen; its Transform.translation.y is now lower
```
### Forces & control
[`PhysicsWorld`] exposes by-entity control so game code never touches rapier
handles: `set_linear_velocity` / `linear_velocity`, `apply_impulse`,
`apply_force`, `apply_torque_impulse`, and `wake`. Reach the resource with
`app.get_resource_mut::<PhysicsWorld>()`.
### Collision & trigger events
When two colliders start or stop touching, the step records a `CollisionEvent`
`{ a, b, started, sensor }`. `started` distinguishes **enter** (`true`) from
**exit** (`false`); `sensor` distinguishes a **trigger** overlap (one collider is
a sensor — nothing was resolved, things pass through) from a **solid contact**.
Events accumulate across every fixed sub-step and are cleared at the first step
of each frame, so a system reading after `Update` sees them all:
```rust
# use oxide_physics::PhysicsWorld;
# fn read(app: &oxide_engine::app::App, player: oxide_engine::scene::Entity) {
let physics = app.get_resource::<PhysicsWorld>().unwrap();
for ev in physics.trigger_events() {
if let Some(other) = ev.other(player) {
if ev.started { /* player entered a trigger zone */ }
}
}
# }
```
"Stay" (ongoing overlap) is not an event — query the current state with
`is_intersecting(a, b)` or `intersecting_pairs()`. Collision filtering uses the
collider `membership`/`filter` `LayerMask`s: two colliders interact only when
each one's membership intersects the other's filter, so a trigger can be made to
fire only for, say, the Player layer.
### Scene queries
[`PhysicsWorld`] answers spatial questions against the simulated colliders, all
filtered by a `LayerMask` (pass `LayerMask::ALL` to hit anything):
| Query | Returns |
|-------|---------|
| `raycast(origin, dir, max_distance, mask)` | first `RayHit { entity, toi, point, normal }` |
| `sphere_cast(origin, radius, dir, max_distance, mask)` | first hit of a swept sphere (a "thick raycast") |
| `overlap_sphere(center, radius, mask)` | every entity whose collider overlaps the sphere |
| `point_overlap(point, mask)` | every entity whose collider contains the point |
```rust
# use oxide_engine::math::Vec3;
# use oxide_engine::layer::LayerMask;
# use oxide_physics::PhysicsWorld;
# fn pick(physics: &PhysicsWorld) {
if let Some(hit) = physics.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL) {
// hit.entity / hit.point / hit.normal
}
# }
```
During simulation the step keeps the query world in sync. To query the **edited**
scene outside Play (e.g. the editor's raycast probe), build a transient world and
sync it once: `sync_to_scene(scene)` rebuilds the rapier world from the scene's
`Collider`/`RigidBody` components and refreshes the query pipeline **without
stepping**, so a following `raycast`/`overlap_*` reflects the current colliders.
```rust
# use oxide_engine::math::Vec3;
# use oxide_engine::layer::LayerMask;
# use oxide_engine::scene::Scene;
# use oxide_physics::PhysicsWorld;
# fn probe(scene: &Scene) {
let mut world = PhysicsWorld::new();
world.sync_to_scene(scene); // query-able, no Play / no step
let _ = world.raycast(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, 100.0, LayerMask::ALL);
# }
```
### Joints / constraints
`add_joint(a, b, kind, anchor_a, anchor_b)` connects two entities' bodies and
returns a `JointId` (`remove_joint` undoes it). Both bodies must already be in
the simulation (true after the first step in which their components exist). The
`JointKind`s and the DOF they leave:
| Kind | Constraint |
|------|-----------|
| `Fixed` | a rigid weld — zero relative DOF |
| `Spherical` | ball-and-socket — anchors stay coincident, free rotation (3 rot. DOF) |
| `Revolute { axis }` | hinge — 1 rotational DOF about `axis` |
| `Prismatic { axis }` | slider — 1 translational DOF along `axis` |
Joints live in the `PhysicsWorld` (not the ECS), so — unlike components — they
are not captured by the play snapshot; game/setup code recreates them on each
Play. Editor-authored joint *components* await a serializable entity-reference
type (backlog).
### Character controller
A `CharacterController` component is a **kinematic capsule** — it never reacts to
forces. The game asks it to move and the controller resolves that against the
world (move-and-slide along walls, auto-step small ledges, snap to ground on
slopes, refuse slopes steeper than `max_slope_degrees`), reporting whether the
character ended up grounded. The entity carries only a `CharacterController` (no
`RigidBody`/`Collider`), so it is never simulated and never self-collides.
```rust
# use oxide_engine::math::Vec3;
# use oxide_physics::PhysicsWorld;
# fn tick(app: &mut oxide_engine::app::App, player: oxide_engine::scene::Entity, dt: f32) {
let desired = Vec3::new(input_x, -9.81 * dt, input_z); // move + gravity
let movement = {
let physics = app.get_resource::<PhysicsWorld>().unwrap();
physics.move_character(&app.scene, player, desired, dt).unwrap()
};
let mut t = app.scene.local_transform(player).unwrap();
t.translation += movement.translation; // apply the resolved move
app.scene.set_local_transform(player, t);
// movement.grounded → e.g. allow jump
# let (input_x, input_z) = (0.0, 0.0);
# }
```
Tune the capsule (`radius`, `half_height`) and the rules (`max_slope_degrees`,
`step_offset`, `snap_to_ground`, `skin_width`) on the component. This is the
basis reused by the Stage-15 prototyping kit's character.
### Limitations (current)
`Transform::scale` is not yet applied to collider dimensions (author the shape at
its true size), and bodies are simulated in world space — keep physics bodies at
the scene root (or under unscaled parents) for now. Both are lifted in later
pieces.
## Roadmap (Stage 9 pieces)
1. **Component data model + module wiring**`RigidBody`, `Collider`,
`PhysicsModule`, `PhysicsSettings`. ✅
2. **Rapier-backed simulation** — build the world from components, step on
`FixedUpdate`, write transforms back, by-entity forces/velocities. ✅
3. **Collision groups/masks via `LayerMask`, sensors, collision/trigger
events** (enter/exit + stay queries). ✅
4. **Scene queries** — raycast, sphere-cast, sphere/point overlap, all
`LayerMask`-filtered. ✅
5. **Joints/constraints** — fixed, spherical, revolute, prismatic (programmatic
API). ✅
6. **Kinematic character controller** — capsule move-and-slide, step offset,
slope limit, grounded. ✅
7. **Examples**`physics_stack` (boxes settle + ball lands) and
`character_capsule` (walk/climb/jump/wall), headless console demos. ✅ (this
piece)
8. Editor: **(8a)** RigidBody/Collider/CharacterController are addable and
editable in the inspector (no per-type code), and `PhysicsModule` runs in
**Play** — drop a body, watch it fall, Stop reverts it (on `dev`, awaiting
eye-check). **(8b)** collider shape wireframe gizmos in the viewport — box,
sphere, capsule, and cylinder outlines, **green** for solid colliders and
**amber** for sensors (triggers), with the selected entity drawn thicker.
The outline mirrors the simulation, which ignores `Transform::scale`, so it
shows the exact shape rapier builds (translation + rotation only). Toggle it
from **View ▸ Show Colliders** (on by default). **(8c)** raycast debug viz —
a **View ▸ Raycast Probe** toggle (off by default, a debug aid to verify the
raycast API and inspect colliders): with it on, a viewport **click** casts the
editor camera→cursor ray against the edited scene's colliders (via
[`PhysicsWorld::sync_to_scene`](#scene-queries), so it needs no Play) and
**freezes** the ray into the world. The viewport then redraws it every frame —
the ray in **cyan**, plus on a hit a **magenta** dot at the surface point and
a short whisker along the surface normal — so **orbiting the camera reveals it
as a real 3D line** (a ray cast from the live camera is otherwise just a point
in that same camera's view). ✅
**Stage 9 is complete** — all pieces are on `main`.
[`PhysicsSettings`]: #adding-physics-to-an-app
[`PhysicsWorld`]: #simulation
+120
View File
@@ -0,0 +1,120 @@
# Editor Play Mode (Stage 8.7)
Play mode lets the editor **run the open scene in place** — play, pause, single-
step, and stop — driving the same fixed-timestep
[`Schedule`](modules.md) a shipped game uses, so what you see while playing
behaves like the real runtime. Mutations made while playing (physics moving
bodies, scripts spawning entities) are reverted on **Stop**, so the authored
scene is never corrupted.
This page covers the play-state model, the snapshot/restore that makes Stop
safe, and how the host runner drives the engine. The standalone **"Launch"**
button (running the *real* exported runtime in a separate process) is a Stage 16
follow-up and is **not** part of play mode.
## The play states
`oxide_editor::state::PlayState` is a three-state machine held on `EditorState`:
| State | Meaning | Schedule ticked? |
|-------|---------|------------------|
| `Editing` | Normal authoring | no |
| `Playing` | Running | every frame (`App::update`) |
| `Paused` | Frozen, still live | only on **Step** (one fixed tick) |
Transitions are methods on `EditorState`:
- `enter_play()` — snapshots the scene and switches to `Playing`. No-op if
already running (re-entering must not clobber the original snapshot).
- `toggle_pause()``Playing``Paused`; no-op while `Editing`.
- `stop()` — restores the snapshot, clears the selection and any in-flight gizmo
drag (entity handles change on restore), and returns to `Editing`.
The shell wraps these with status messages and undo-history clearing (see
[Controls](#controls)); `is_in_play()` is the "running or paused" predicate the
host uses to gate the runtime.
## Snapshot / restore
Pressing Play captures a [`SceneSnapshot`](scene.md) of the current scene;
pressing Stop restores it. Unlike `Scene::to_ron` (which records only the node-
baked `Node`/`Transform`/hierarchy), a snapshot is **registry-aware**: it also
serializes every reflected component on each entity, plus the engine-intrinsic
non-reflected components (`Tags` and `DisabledComponents`). That makes Stop a
**bit-for-bit** revert across the full component set, not just transforms.
```rust
use oxide_engine::scene::SceneSnapshot;
let snap = scene.snapshot(&registry); // capture
// … play-mode mutations …
let scene = snap.restore(&registry)?; // revert (fresh entity handles)
```
Fidelity is bounded by what the [`TypeRegistry`](reflection.md) knows: a
component that is neither registered nor one of the two intrinsics is invisible
to capture. Modules register their components anyway (that is what makes them
editable), so they survive play mode automatically.
`SceneSnapshot` also exposes `to_ron`/`from_ron`, so the same capture format
seeds full scene files in a later stage.
## Driving the schedule
The editor holds the live scene in `EditorState.scene`, not in an
[`App`](modules.md). On Play the host runner (`oxide_editor::main`) builds a play
`App` (currently just `DefaultModules`; Stage 9's physics module and the
project's modules will register here too) and, each frame:
1. **swaps** `state.scene` into `app.scene` (an O(1) move),
2. advances the engine, then
3. **swaps** the scene back out.
So the `App` only "holds" the editor scene for the duration of a tick, and
`state.scene` stays the single source of truth the inspector, hierarchy, and
viewport read between frames. The `App` persists its `Time` and resources across
frames (so a physics world accumulates correctly) and is dropped on Stop.
How far to advance is decided by a small pure function,
`oxide_editor::play::tick_for`, kept separate from the (un-testable) GUI runner
so the contract is pinned in a unit test:
```rust
use oxide_editor::play::{tick_for, Tick};
match tick_for(state.play, step_requested) {
Tick::Frame => app.update(dt), // Playing
Tick::FixedStep => app.step(), // Paused + Step
Tick::Idle => {} // Editing, or Paused with no Step
}
```
`App::step()` (engine) advances **exactly one fixed timestep** — one
`FixedUpdate` plus the per-frame phases once, bypassing the accumulator — which
is the Step primitive. `App::update(dt)` runs a normal frame, fixed steps driven
by the accumulator as usual (see [modules.md](modules.md)).
## Controls
A toolbar below the menu bar exposes **Play / Pause / Step / Stop**, gated by the
play state (Pause/Step/Stop enable only while running) with an
`Editing`/`PLAYING`/`PAUSED` badge. The viewport additionally draws a coloured
border + corner label while running (green = playing, amber = paused) so
edit-vs-play is unmistakable.
Shortcuts:
- **Ctrl+P** — Play when editing; Pause ⇄ Resume while running.
- **Ctrl+.** — Step one fixed tick (while paused).
Entering Play and pressing Stop both **clear the undo history**: the scene is
restored wholesale on Stop, so play-mode edits are deliberately not part of
edit-mode undo. The reflection inspector and gizmos stay live while paused (and
playing), so a field can be tweaked and the result observed immediately — the
payoff of the Stage-8.5 reflection work.
## Not in play mode (Stage 16)
A **"Launch standalone"** button that runs the real exported runtime in a
separate window/process — the truest-to-ship check — is deferred to Stage 16,
where it reuses the export builder rather than the in-editor loop.
+83
View File
@@ -0,0 +1,83 @@
# Prefabs
`oxide_engine::prefab` provides **named spawn templates**. A prefab is a thing
you can "drop into the scene" that already carries a set of components — a
`Cube`, a `Camera`, a `Directional Light` — without the editor or game code
hard-coding one spawn path per kind of object.
## An entity is its components — a prefab is just data
Oxide has **no parallel "object type" system**. An entity *is* its set of
components (see [scene.md](scene.md) and [reflection.md](reflection.md)). A
[`Prefab`] is therefore nothing more than a **name** plus a list of
**(component name, RON value)** specs, applied on spawn through the
[`TypeRegistry`](reflection.md):
> "Spawn a Cube" = spawn an entity, then set its `MeshRenderer` to a cube.
Because a spec is the same name-keyed RON the editor and scripts already use,
prefabs are **pure data**: serializable, dual-editable, and free of bespoke
code. This is what lets the editor's add-menu be **data-driven** — it lists the
prefabs in a [`PrefabRegistry`] instead of one hard-coded button per type.
## The types
- [`ComponentSpec`] — `{ type_name, ron }`: one component to attach. Build it
from a value with [`ComponentSpec::of`] (serializes to RON) or from a raw
string with [`ComponentSpec::new`].
- [`Prefab`] — `{ name, components }`: the node name plus the specs to apply.
Built fluently with [`Prefab::new`] + [`Prefab::with`].
- [`PrefabRegistry`] — prefabs keyed by name; the source for an add-menu.
Every spawned entity already carries the node-baked `Node`, `Transform`, and
`Layer` (auto-attached by [`Scene::spawn`](scene.md)); a prefab's specs are
layered on top. A spec named `"Transform"` overrides the identity transform
`spawn` starts with, so a prefab can place itself.
## Spawning
```rust
use oxide_engine::prelude::*;
use oxide_engine::prefab::{ComponentSpec, Prefab, PrefabRegistry};
use oxide_engine::reflect::TypeRegistry;
// A registry that knows how to round-trip MeshRenderer by name.
let mut types = TypeRegistry::new();
types.register_reflected::<MeshRenderer>("MeshRenderer");
// A "Cube" prefab: a default MeshRenderer (shape = Cube).
let mut prefabs = PrefabRegistry::new();
prefabs.register(
Prefab::new("Cube")
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
);
let mut scene = Scene::new();
let cube = prefabs.spawn("Cube", &mut scene, &types).unwrap(); // root entity
let child = prefabs.spawn_child("Cube", cube, &mut scene, &types); // parented
```
[`PrefabRegistry::spawn`] returns the new entity, or `None` if the name isn't
registered. Application is **best-effort**: a spec whose type isn't registered
or whose RON doesn't parse is skipped (the entity is still created with whatever
applied). Validate a prefab against a registry up front with
[`PrefabRegistry::unknown_specs`], which lists the type names the registry
doesn't know — handy for catching authoring typos.
## Relationship to "multiple of the same component"
Prefabs spawn **one entity**. Because an archetypal ECS allows only one
component of a given type per entity, a prefab that needs several of a thing
(e.g. multiple meshes) composes them as **child entities** — spawn the prefab,
then `spawn_child` the extras (each a real, gizmo-movable node). See the
component-multiplicity notes in `PLAN.md`.
[`ComponentSpec`]: ../engine/src/prefab.rs
[`ComponentSpec::of`]: ../engine/src/prefab.rs
[`ComponentSpec::new`]: ../engine/src/prefab.rs
[`Prefab`]: ../engine/src/prefab.rs
[`Prefab::new`]: ../engine/src/prefab.rs
[`Prefab::with`]: ../engine/src/prefab.rs
[`PrefabRegistry`]: ../engine/src/prefab.rs
[`PrefabRegistry::spawn`]: ../engine/src/prefab.rs
[`PrefabRegistry::unknown_specs`]: ../engine/src/prefab.rs
+85
View File
@@ -0,0 +1,85 @@
# Project System
`oxide_engine::project` defines what a game is, on disk: a **project**. A project
is a root directory containing a project file plus a defined folder layout. The
format lives in the engine (not the editor) because the exported runtime and the
Stage-16 packer read it too — the editor just adds the create/open/save UI.
## Layout
```
my-game/
├── project.oxide # the project file (RON)
├── scenes/ # scene files
├── assets/ # meshes, textures, audio, …
└── scripts/ # game scripts
```
The project file records the project name, the engine version it was saved with,
the enabled [modules](modules.md), and per-project settings.
## Create, open, save
```rust
use oxide_engine::project::Project;
// Scaffold a new project (creates the folders + project file).
let mut project = Project::create("/path/to/my-game", "My Game")?;
project.enable_module("render");
project.save()?;
// Reopen later — by directory or by the project file path.
let project = Project::open("/path/to/my-game")?;
assert_eq!(project.name(), "My Game");
assert!(project.is_module_enabled("render"));
# Ok::<(), oxide_engine::project::ProjectError>(())
```
Path helpers (`scenes_dir()`, `assets_dir()`, `scripts_dir()`,
`project_file_path()`) resolve locations against the root. `create` refuses to
overwrite an existing project; `open` reports `NotFound` when there is no project
file.
## Settings storage
Per-project settings are stored as **opaque per-section RON blobs** keyed by
section name, which keeps the project format independent of any particular
settings schema:
```rust
# use oxide_engine::project::Project;
# let mut project = Project::create(std::env::temp_dir().join("oxide_doc_proj"), "x").unwrap();
project.set_settings_section("editor", "(theme:\"dark\")");
assert_eq!(project.settings_section("editor"), Some("(theme:\"dark\")"));
```
The typed settings framework serializes its sections to and from these strings,
so a section round-trips through the project file without this module knowing the
section's shape.
## Recent projects
`RecentProjects` is a small most-recently-used list, persisted globally as an
editor preference (not inside any project). It de-duplicates and caps:
```rust
use oxide_engine::project::RecentProjects;
let mut recent = RecentProjects::new(10);
recent.record("/path/to/my-game");
// recent.save("~/.config/oxide/recent.ron")?; / RecentProjects::load(...)
assert_eq!(recent.entries().len(), 1);
```
## Who consumes this
| Consumer | Stage | Use |
|----------|-------|-----|
| Editor | 6 | New/Open/Save Project UI, recent list, Project panel/asset browser |
| File watcher | 6 | watches `scenes/`, `assets/`, `scripts/` for live reload |
| Settings framework | 6 | persists per-project sections into the project file |
| Exported runtime / packer | 16 | reads the layout + enabled modules to bundle the game |
[`Project`]: ../engine/src/project.rs
[`RecentProjects`]: ../engine/src/project.rs
+191
View File
@@ -0,0 +1,191 @@
# Reflection / Type Registry
`oxide_engine::reflect` is the backbone of the engine's **dual-editable types**
principle: every component should be readable and writable from the editor, from
scripts, and from external tools through *one* representation — without each of
those callers knowing the concrete Rust type.
The [`TypeRegistry`] is that bridge. It lands in Stage 5; the editor inspector
(Stage 6) and the scripting layer (Stage 10) are its first real consumers.
## The idea
ECS components are concrete Rust types. An inspector panel or a script engine,
however, only has a *name* (`"Transform"`) and some text — they cannot name the
type at the call site. The registry closes that gap: register a type once, and
afterwards address it generically by name.
```rust
use oxide_engine::reflect::TypeRegistry;
use oxide_engine::prelude::*;
let mut registry = TypeRegistry::new();
registry.register::<Transform>("Transform");
registry.register::<Node>("Node");
```
`register::<T>(name)` requires `T: Component + Serialize + DeserializeOwned`.
Internally it stores a small set of monomorphized function pointers, so there is
no per-call generic dispatch and no `dyn Any` downcasting at the boundary.
## Generic read / write
Once registered, any holder of the name can round-trip a component on an entity
as RON text — exactly what a generic inspector or a script needs:
```rust
# use oxide_engine::reflect::TypeRegistry;
# use oxide_engine::prelude::*;
# let mut registry = TypeRegistry::new();
# registry.register::<Transform>("Transform");
let mut scene = Scene::new();
let e = scene.spawn("thing", Transform::IDENTITY);
// Read generically...
let text = registry.get_ron(scene.world(), e, "Transform").unwrap();
// ...edit the text (a script or the inspector would)...
// ...and write it back — no concrete type at the call site.
registry.set_ron(scene.world_mut(), e, "Transform", &text).unwrap();
```
`set_ron` inserts the component if absent or replaces it if present, so the same
call covers "add component" and "edit component".
## Enumerating an entity's components
A generic inspector renders an entity by asking the registry which *registered*
component types it currently carries — sorted, and again with no concrete types
in hand:
```rust
# use oxide_engine::reflect::TypeRegistry;
# use oxide_engine::prelude::*;
# let mut registry = TypeRegistry::new();
# registry.register::<Transform>("Transform");
# registry.register::<Node>("Node");
# let mut scene = Scene::new();
# let e = scene.spawn("thing", Transform::IDENTITY);
for name in registry.components_on(scene.world(), e) {
let _ron = registry.get_ron(scene.world(), e, name).unwrap();
// ... render an editor for `name` from its RON text
}
```
`has` and `remove` round out the surface (check for / detach a component by
name). Errors are specific — [`UnknownType`], [`NoSuchEntity`], [`Missing`], and
[`Parse`] — so callers can tell "no such type" from "bad text".
## Per-field reflection — `#[derive(Reflect)]` (Stage 8.5)
Whole-value reflection is enough for serialization and scripts, but a
Unity/Godot-style inspector needs to see a component's **named fields** so it
can render one widget per field. The [`Reflect`] trait provides that, and
`#[derive(Reflect)]` generates it:
```rust
use oxide_engine::reflect::Reflect;
#[derive(Reflect, serde::Serialize, serde::Deserialize)]
struct Timer {
pub repeating: bool,
pub duration: f32,
#[reflect(skip)]
pub elapsed: f32, // runtime state — not an authored field
}
let mut t = Timer { repeating: true, duration: 2.5, elapsed: 0.0 };
// Enumerate fields (name + syntactic type) — what an inspector iterates.
for field in t.fields() {
let _value_ron = t.get_field(field.name); // Some("true"), Some("2.5"), …
// pick a widget from `field.type_name`: "bool" → checkbox, "f32" → drag, …
}
// Edit one field without touching the rest; round-trips as RON.
t.set_field("duration", "9.0").unwrap();
```
Selection rules (deliberate, matching the engine's *public-fields-are-the-
editable-surface* convention):
- **Only `pub` fields** are reflected. Private fields are implementation detail.
- `#[reflect(skip)]` excludes a public field (e.g. runtime-only state).
- Each reflected field must be `serde`-serializable — get/set round-trip through
RON, the same representation the whole-value path uses.
[`FieldInfo::type_name`] is the field type's *syntactic* spelling (`"f32"`,
`"bool"`, `"Vec3"`, `"Handle < Font >"`). The generic inspector dispatches a
widget on it and falls back to a raw RON editor for types it does not recognize.
Per-field errors are [`UnknownField`] and [`FieldParse`].
The derive lives in the small `oxide-engine-derive` proc-macro crate and is
re-exported as `oxide_engine::reflect::Reflect` (the macro shares its name with
the trait, the same way `serde`'s `Serialize` does). This is the spine of the
reflection-driven editor inspector and the dual-editable-types principle — a new
component becomes editor- and script-editable with `#[derive(Reflect)]` plus one
registration line, no per-type editor code.
### Through the registry: fields by type name + entity
A type registered with `register_reflected::<T>("Name")` (instead of plain
`register`) exposes its fields through the [`TypeRegistry`] too, so the editor
can reach a field given only a **type name + entity** — no concrete type at the
call site:
```rust
# use oxide_engine::reflect::TypeRegistry;
# use oxide_engine::prelude::*;
# let mut registry = TypeRegistry::new();
registry.register_reflected::<Transform>("Transform");
# let mut scene = Scene::new();
# let e = scene.spawn("thing", Transform::IDENTITY);
for field in registry.field_infos(scene.world(), e, "Transform").unwrap() {
let _ron = registry.get_field(scene.world(), e, "Transform", field.name).unwrap();
// render a widget from field.type_name, write edits back with set_field(...)
}
```
`Transform` and `Node` are registered reflected by default. Field access on a
whole-value-only type returns [`NotReflected`]. Whole-value (`get_ron`/`set_ron`)
and per-field (`field_infos`/`get_field`/`set_field`) coexist: the registry
addresses *types* by name; per-field reaches *fields* within a value.
### Enum fields — `#[derive(ReflectEnum)]`
Per-field reflection tells the inspector a field's *type name* but not, for an
enum-typed field, which values it may take. `#[derive(ReflectEnum)]` (unit
variants only) exposes the variant list so the inspector renders a dropdown
instead of a free-text RON box:
```rust
use oxide_engine::reflect::{ReflectEnum, TypeRegistry};
#[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
enum Facing { North, East, South, West }
let mut registry = TypeRegistry::new();
registry.register_enum::<Facing>("Facing");
assert_eq!(registry.enum_variants("Facing"), Some(["North","East","South","West"].as_slice()));
```
Each variant name is valid RON for that unit variant, so a chosen name writes
straight back through `set_field`. An enum is a field *type*, not a component, so
`register_enum` is independent of component registration.
## Who owns the registry
The registry is owned by the app / module system (Stage 5): each module
registers the component types it introduces, so the editor and scripts can reach
every type any module added. Because names are the identity used in serialized
data and UI, keep them stable across versions.
[`TypeRegistry`]: ../engine/src/reflect.rs
[`UnknownType`]: ../engine/src/reflect.rs
[`NoSuchEntity`]: ../engine/src/reflect.rs
[`Missing`]: ../engine/src/reflect.rs
[`Parse`]: ../engine/src/reflect.rs
[`Reflect`]: ../engine/src/reflect.rs
[`FieldInfo::type_name`]: ../engine/src/reflect.rs
[`UnknownField`]: ../engine/src/reflect.rs
[`FieldParse`]: ../engine/src/reflect.rs
[`NotReflected`]: ../engine/src/reflect.rs
+105
View File
@@ -0,0 +1,105 @@
# Render Context & GPU Setup
Stage 2 reference for `oxide_engine::render` — how the engine acquires the
GPU and drives a window surface. For the event loop that calls into this each
frame, see [windowing.md](windowing.md).
## Overview
Stage 2 rendering is deliberately minimal: acquire the GPU, configure the
window surface, and clear it to a configurable color every frame. Meshes,
materials, and passes arrive in Stage 4+. The module still establishes the
two long-lived types every later stage builds on:
- **`Gpu`** — instance, adapter, and the device/queue pair. Everything that
touches the GPU goes through these four objects.
- **`RenderContext`** — a `Gpu` plus a window's surface and its
configuration; owns the per-frame acquire → clear → present cycle.
Both are created for you by [`run()`](windowing.md); applications normally
reach them through `AppCtx::render()`.
## `Gpu`
```rust
use oxide_engine::prelude::*;
let gpu = Gpu::headless()?; // offscreen / tests
let device: &wgpu::Device = gpu.device();
let queue: &wgpu::Queue = gpu.queue();
# Ok::<(), oxide_engine::render::RenderError>(())
```
Acquisition asks for a high-performance adapter (compatible with the window
surface in the windowed path) and a default-limits device. The chosen adapter
and backend are logged at `info` level on startup.
`Gpu::headless()` skips the surface entirely — used by offscreen rendering
and the automated Stage 2 integration test. Backend selection and debug flags
remain overridable through wgpu's standard `WGPU_*` environment variables
(e.g. `WGPU_BACKEND=vulkan`).
## `RenderContext`
Owns the surface lifecycle:
- **Creation** — builds the wgpu instance (the window doubles as the display
handle), creates the surface, acquires the `Gpu`, and configures the
surface with `get_default_config` (the platform's preferred format and
present mode).
- **`resize(width, height)`** — reconfigures the surface. Zero dimensions
(minimized windows) are clamped to 1 so the surface stays valid. Called
automatically by the event loop on `Resized`.
- **`set_clear_color(color)` / `clear_color()`** — the color the next frame
is cleared to. The engine's `Color` is linear f32 RGBA, matching what the
surface expects (conversion to `wgpu::Color` is `render::to_wgpu_color`).
- **`render_frame()`** — one frame: acquire the next surface texture, record
a clear pass, submit, present.
- **`size()`, `gpu()`** — current surface size (physical pixels) and the
underlying `Gpu`.
### Frame acquisition and transient failures
`get_current_texture` can fail for reasons that are *normal* during resizes
and window-manager activity. `render_frame()` maps them as follows:
| Surface state | Behavior |
|---------------|----------|
| `Success` / `Suboptimal` | Clear and present (a suboptimal frame is still presentable; the next resize reconfigures anyway) |
| `Lost` / `Outdated` | Reconfigure the surface, skip the frame |
| `Timeout` / `Occluded` | Skip the frame |
| `Validation` | Returned as `RenderError::SurfaceValidation` — a real bug, not transient |
Skipped frames are invisible in practice: the next `RedrawRequested` arrives
within milliseconds.
## `clear_view`
The single render operation Stage 2 owns:
```rust
oxide_engine::render::clear_view(device, queue, &texture_view, Color::RED);
```
Records and submits a render pass whose only work is a load-op clear. Both
the windowed path (`render_frame`) and offscreen targets go through it, which
is what makes the GPU path automatically testable: the integration test
`stage2::headless_clear_fills_texture_with_clear_color` clears an offscreen
texture headless, reads the pixels back, and asserts the exact clear color —
no window or human needed. (It self-skips on machines with no GPU adapter.)
## Errors
`RenderError` (a `thiserror` enum) distinguishes the failure modes callers
might handle: `NoAdapter`, `Device`, `CreateSurface`, `UnsupportedSurface`,
and `SurfaceValidation`. Binaries typically just propagate it via `anyhow`
out of `run()`.
## Design notes
- The window is held as `Arc<winit::window::Window>` so the surface, which
borrows the window, can be `'static` — winit hands windows out from inside
its event loop, and wgpu surfaces must outlive every frame.
- `Gpu` and `RenderContext` are separate types on purpose: later stages (and
tests today) need the device/queue without any window, and composability is
a core project principle.
+115
View File
@@ -0,0 +1,115 @@
# Render Pass Pipeline
Stage 4 drew everything in one hardcoded pass. Stage 5 generalizes that into a
[`RenderPipeline`]: an ordered, named list of composable [`RenderPass`]es that
share one frame's targets. A project enables only the passes it needs — this is
the mechanism behind **scalable fidelity**: a flat unlit/low-poly look (or a
stylized post effect like a VCR filter) versus a full realistic stack with
shadows and post-processing, paying only for the passes turned on.
The Stage-4 forward renderer is retrofitted onto this as [`ForwardPass`], so the
default pipeline is just `[Clear, Forward]` and produces pixel-identical output.
Later stages (shadows, post-process, overlay UI) add passes **without touching
the renderer core** — they register a pass.
## The pieces
- [`RenderPass`] — a trait with one method, `run(&mut self, frame)`. Implement it
to add a stage of the frame.
- [`FrameContext`] — everything a pass operates on for one frame: the shared
`color` target, size, clear color, camera + its world transform, lighting, and
the (already culled) drawables.
- [`RenderPipeline`] — owns the passes and runs every *enabled* one in order.
- Built-in passes: [`ClearPass`] (clears the color target) and [`ForwardPass`]
(the lit forward draw).
## Composing a frame
```rust
use oxide_engine::render::{RenderPipeline, FrameContext, ForwardPass};
# use oxide_engine::prelude::*;
# fn demo(device: &oxide_engine::wgpu::Device, queue: &oxide_engine::wgpu::Queue,
# target: &oxide_engine::wgpu::TextureView, cube: &GpuMesh) {
// The default pipeline: Clear then Forward (pixel-identical to Stage 4).
let mut pipeline = RenderPipeline::forward(device, oxide_engine::wgpu::TextureFormat::Rgba8Unorm);
let camera = Camera::default();
let view = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
let lighting = Lighting::default();
let objects = [RenderObject { mesh: cube, material: Material::diffuse(Color::RED), transform: Transform::IDENTITY }];
pipeline.render(&mut FrameContext {
device, queue,
color: target,
size: (1280, 720),
clear_color: Color::rgb(0.05, 0.06, 0.09),
camera: &camera,
view_transform: &view,
lighting: &lighting,
objects: &objects,
});
# }
```
## Data-driven: add, toggle, remove
Passes are addressed by name and managed without touching any pass's code:
```rust
# use oxide_engine::render::RenderPipeline;
# struct Bloom; impl oxide_engine::render::RenderPass for Bloom {
# fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} }
# let mut pipeline = RenderPipeline::new();
pipeline.add_pass("forward", /* ForwardPass */
# { struct F; impl oxide_engine::render::RenderPass for F { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } F }
);
pipeline.add_pass("bloom", Bloom); // a post effect (Stage 13)
pipeline.set_enabled("bloom", false); // turn it off, keep it registered
pipeline.insert_before("forward", "shadows",
# { struct S; impl oxide_engine::render::RenderPass for S { fn run(&mut self, _f: &mut oxide_engine::render::FrameContext<'_>) {} } S }
); // slot a pass into a fixed position
pipeline.remove("bloom"); // drop it entirely
```
A stylized game ships a pipeline with no post passes (and pays nothing for them);
a realistic game enables shadows, SSAO, bloom, tone-mapping. Same engine, same
renderer core — different pass list.
## Windowed vs offscreen clearing
The window runner already clears the surface to the configured clear color before
`App::render` runs, so the **editor and windowed examples use a forward-only
pipeline** (no `ClearPass`) and let the runner clear. `RenderPipeline::forward`
(Clear + Forward) is for offscreen/standalone rendering where nothing else
clears the target — e.g. the headless render tests.
## Camera layer visibility
A [`Camera`](rendering.md) carries a `visibility` [`LayerMask`](layers.md): it
renders an entity only if the entity's [`Layer`] is in that mask (default
[`LayerMask::ALL`] — sees everything). The host applies it while gathering
drawables:
```rust
# use oxide_engine::prelude::*;
# use oxide_engine::layer::Layer;
# let scene = Scene::new();
# let camera = Camera::default();
# let entity = scene.entities().next();
# if let Some(entity) = entity {
let layer = scene.get::<Layer>(entity).map(|l| *l).unwrap_or_default();
if camera.sees(layer) {
// include this entity in the draw list
}
# }
```
This is how a minimap camera, a first-person view-model camera, or editor-only
gizmo layers are kept to their own cameras.
[`RenderPipeline`]: ../engine/src/render/pipeline.rs
[`RenderPass`]: ../engine/src/render/pipeline.rs
[`FrameContext`]: ../engine/src/render/pipeline.rs
[`ClearPass`]: ../engine/src/render/pipeline.rs
[`ForwardPass`]: ../engine/src/render/pipeline.rs
[`Layer`]: ../engine/src/layer/components.rs
+186
View File
@@ -0,0 +1,186 @@
# Rendering (Stage 4 — Basic 3D Rendering)
Stage 4 turns the clear-color surface from Stage 2 into a 3D renderer: it draws
**meshes**, placed by **transforms**, shaded by **materials**, as seen through a
**camera**, lit by a directional light — all through a single-pass
**forward renderer**.
> Status: the rendering core (this document), the glTF importer, and the
> editor's 3D viewport (orbit/pan/zoom + material inspector) are all implemented.
> The engine paths are covered by headless GPU tests; the editor viewport is on
> `dev` awaiting the maintainer's manual sign-off before Stage 4 is marked done
> (tracked in [PLAN.md](../PLAN.md)).
All of these types live in `oxide_engine::render` and are re-exported from the
[prelude](getting-started.md).
## The pieces
| Type | Role |
|------|------|
| [`Vertex`] | One vertex: `position`, `normal`, `uv` (GPU-ready, `repr(C)`) |
| [`Mesh`] | CPU-side indexed triangle geometry + primitive builders |
| [`GpuMesh`] | A `Mesh` uploaded into GPU vertex/index buffers |
| [`Material`] | PBR-lite surface: `albedo`, `metallic`, `roughness` |
| [`Camera`] | Perspective projection; the *view* comes from a `Transform` |
| [`DirectionalLight`] / [`Lighting`] | One sun light + an ambient term |
| [`RenderObject`] | A drawable: `&GpuMesh` + `Material` + `Transform` |
| [`ForwardRenderer`] | Owns the pipeline + depth buffer; draws a list of objects |
[`Vertex`]: ../engine/src/render/mesh.rs
[`Mesh`]: ../engine/src/render/mesh.rs
[`GpuMesh`]: ../engine/src/render/mesh.rs
[`Material`]: ../engine/src/render/material.rs
[`Camera`]: ../engine/src/render/camera.rs
[`DirectionalLight`]: ../engine/src/render/forward.rs
[`Lighting`]: ../engine/src/render/forward.rs
[`RenderObject`]: ../engine/src/render/forward.rs
[`ForwardRenderer`]: ../engine/src/render/forward.rs
## Building geometry
Meshes are built on the CPU and uploaded once. Built-in primitives cover the
common prototyping shapes:
```rust
use oxide_engine::prelude::*;
let cube = Mesh::cube(); // unit cube, per-face normals
let plane = Mesh::plane(10.0); // 10×10 ground on XZ, facing +Y
let sphere = Mesh::uv_sphere(0.8, 32, 16); // radius, sectors, stacks
// Upload to the GPU (needs a `&wgpu::Device`, e.g. from `RenderCtx`/`Gpu`).
let gpu_cube: GpuMesh = cube.upload(device, "cube");
```
You can also build a mesh directly from `Vertex` + index data, and query its
object-space bounds with `Mesh::bounds()` (used later for culling).
### Importing glTF
Static meshes load from glTF/GLB via `oxide_engine::asset`. The node hierarchy is
flattened into world space and each primitive becomes a `GltfMesh` (geometry +
PBR-lite material + transform); missing normals are generated, missing UVs default
to zero. Skinning/animation are deferred to the animation stage.
```rust
use oxide_engine::prelude::*;
let model = load_gltf("assets/models/cube.gltf")?;
let drawables: Vec<_> = model
.meshes
.iter()
.map(|m| (m.mesh.upload(device, "gltf"), m.material, m.transform))
.collect();
// Build `RenderObject`s from `drawables` and hand them to `ForwardRenderer::render`.
```
`load_gltf_slice(&bytes)` is the in-memory variant (buffers must be embedded),
used for tests and bundled assets.
## Camera
A `Camera` holds only projection parameters (`fov_y`, `z_near`, `z_far`); its
*position and orientation* are a [`Transform`](scene.md) given at render time, so
a camera can live in the scene as an entity. Use `Transform::looking_at` to aim
it:
```rust
let camera = Camera::default(); // 60° FOV, 0.11000 range
let view = Transform::looking_at(Vec3::new(4.0, 2.5, 5.0), Vec3::ZERO, Vec3::Y);
```
The projection uses a `0..1` NDC depth range (the wgpu/Vulkan/DX/Metal
convention), matching the depth buffer the forward renderer clears to `1.0`.
## Drawing a frame
The `ForwardRenderer` is built once for a given **color target format** — the
window surface format for on-screen rendering, or e.g. `Rgba8Unorm` offscreen.
Then each frame you hand it a list of `RenderObject`s:
```rust
// Once (e.g. lazily on the first frame, when the surface format is known):
let mut renderer = ForwardRenderer::new(device, ctx.surface_format);
// Each frame, inside `App::render`:
renderer.render(
device,
queue,
ctx.view, // the target view (already cleared to the clear color)
ctx.size, // (width, height) in physical pixels
&camera,
&view, // the camera's world transform
&Lighting::default(),
&[
RenderObject { mesh: &gpu_plane, material: Material::diffuse(Color::WHITE), transform: ground },
RenderObject { mesh: &gpu_cube, material: Material::diffuse(Color::RED), transform: spin },
],
);
```
The color attachment is **loaded, not cleared**, so whatever cleared the surface
beforehand (the window's clear color from Stage 2, or a `clear_view` call) shows
through as the background. The depth buffer is owned by the renderer, resized to
match the target, and cleared to `1.0` every call.
See the full runnable example:
```sh
cargo run -p oxide-examples --bin hello_mesh # spinning cube + sphere + ground
```
## How it works
- **One pipeline, one pass.** Geometry is drawn front-to-back-agnostic; a
`Depth32Float` depth buffer with `Less` compare resolves occlusion, so draw
order does not affect the result.
- **Per-object data via dynamic uniform offsets.** Globals (view-projection,
camera position, light) live in one uniform buffer (bind group 0). Each
object's model matrix, normal matrix, and material live in a second uniform
buffer addressed with a dynamic offset (bind group 1), so an arbitrary number
of objects draw from one buffer that grows as needed.
- **PBR-lite shading.** [`shaders/lit.wgsl`](../engine/src/render/shaders/lit.wgsl)
does Lambert diffuse + ambient + a Blinn-Phong specular term whose sharpness
comes from `roughness` and whose color comes from `metallic`. It outputs linear
color; an sRGB surface converts on write.
## In the editor
The editor renders the active scene in a 3D viewport beneath its egui panels.
Entities become visible by carrying a `MeshRenderer` component (`oxide_engine::render`):
```rust
use oxide_engine::prelude::*;
// Make an entity render a cube with a custom material.
let e = scene.spawn("crate", Transform::from_translation(Vec3::new(2.0, 0.5, 0.0)));
scene.world_mut().insert_one(
e,
MeshRenderer::with_material(PrimitiveShape::Cube, Material::diffuse(Color::RED)),
).unwrap();
```
`MeshRenderer` names a built-in `PrimitiveShape` (cube/sphere/plane) rather than
embedding geometry, so it is tiny and serializable (RON) — editable from both the
inspector and, later, scripts/AI agents. The editor caches one GPU mesh per shape
and draws every `MeshRenderer` entity through the `ForwardRenderer`, with an
orbit camera (drag to orbit, right-drag to pan, scroll to zoom).
Entities are selected by **clicking them in the viewport** (a ray is cast against
each renderable's world-space bounds) or from the hierarchy panel. The inspector
edits the selection's **transform** (position, rotation as euler degrees, and
scale) and its **material** (albedo / metallic / roughness — roughness controls
specular-highlight sharpness, most visible on glossy/metallic surfaces).
## Testing
The window/viewport halves need a human eye, but the render path itself is
verified headlessly (`tests/` `stage4`): render to an offscreen texture and read
the pixels back to assert that lit geometry appears, the background shows through
elsewhere, and a near object occludes a farther one through the depth buffer.
Camera projection/view math has unit tests in `render/camera.rs`.
See also: [render-context.md](render-context.md) (surface/clear loop),
[conventions.md](conventions.md) (handedness, color space), [scene.md](scene.md)
(transforms and the hierarchy that feeds object placement).
+232
View File
@@ -0,0 +1,232 @@
# Scene Graph & Entity System
The `oxide_engine::scene` module is the world model every later system plugs
into. It pairs a lightweight ECS ([`hecs`](https://docs.rs/hecs)) with a
parent/child [`Transform`](math.md) hierarchy, so entities can hold arbitrary
components *and* live in a spatial tree.
This document is the usage reference for the module as delivered in **Stage 3**.
For the math types it builds on, see [math.md](math.md); for coordinate and
units conventions, see [conventions.md](conventions.md).
## Importing
```rust
use oxide_engine::scene::{Scene, Node, Entity, DespawnPolicy, SceneError};
// or, for the common types, via the prelude:
use oxide_engine::prelude::*; // Scene, Node, Entity, DespawnPolicy, SceneError, Transform, …
```
The full ECS is re-exported as `oxide_engine::hecs` so you share one copy of
`Entity` and the query API with the engine.
## Mental model
- An **entity** is a `hecs::Entity` handle — a small `Copy` id.
- Every entity created through the scene carries a [`Node`](#node) (name +
enabled flag) and a **local** [`Transform`](math.md).
- The **hierarchy** (which entity parents which) is owned by the `Scene`, not
stored as components. This keeps child ordering deterministic and makes
reparenting cheap.
- A **local** transform is what you author. A **world** transform is the local
composed with every ancestor: `world = parent_world * local`. The scene
resolves these on demand; it does not cache them.
| Type | Role |
|------|------|
| [`Scene`](#scene) | Owns entities + hierarchy; spawn, despawn, reparent, query, resolve transforms |
| [`Node`](#node) | Per-entity metadata: `name`, `enabled` |
| [`DespawnPolicy`](#despawning) | Whether despawn takes the subtree or detaches children |
| [`SceneError`](#errors) | Reparent / (de)serialization failures |
---
## `Scene`
### Building a hierarchy
```rust
use oxide_engine::prelude::*;
let mut scene = Scene::new();
// A root entity (no parent).
let sun = scene.spawn("sun", Transform::IDENTITY);
// Children. `spawn_child` panics if the parent is not a live entity.
let planet = scene.spawn_child(
sun,
"planet",
Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)),
);
let moon = scene.spawn_child(
planet,
"moon",
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
);
```
`spawn`/`spawn_child` take anything that converts into a `Node`, so a bare
`&str` works as a name (`"planet"``Node::new("planet")`); pass a `Node`
directly when you need to set `enabled`.
### Resolving world transforms
```rust
// One entity (walks up the parent chain):
let moon_world = scene.world_transform(moon).unwrap();
// Every entity at once (single top-down pass — prefer this in bulk):
let worlds = scene.world_transforms(); // HashMap<Entity, Transform>
```
`world_transforms()` is the path the renderer will use; it resolves a
10,000-entity, 5-level scene in well under a millisecond (see the
`world_transforms_10k_depth5` benchmark).
### Reparenting
```rust
scene.set_parent(moon, Some(sun))?; // moon now orbits the sun directly
scene.set_parent(moon, None)?; // moon becomes a root
```
Reparenting preserves the **local** transform (it does not compensate to keep
the world transform fixed). Cycles are rejected: parenting an entity to itself
or to one of its descendants returns [`SceneError::WouldCycle`], leaving the
hierarchy untouched.
### Despawning
```rust
use oxide_engine::scene::DespawnPolicy;
// Remove the entity and its entire subtree:
scene.despawn(planet, DespawnPolicy::Recursive);
// Remove only the entity; its children move up to its parent (or become roots
// if it was a root):
scene.despawn(planet, DespawnPolicy::DetachChildren);
```
### Editing nodes
```rust
scene.set_name(planet, "earth");
scene.set_enabled(moon, false); // later systems skip disabled subtrees
scene.set_local_transform(moon, Transform::IDENTITY);
let name = scene.name(planet); // Option<String>
let on = scene.is_enabled(moon); // Option<bool>
let local = scene.local_transform(moon); // Option<Transform>
```
### Querying
```rust
for &root in scene.roots() { /**/ }
for &child in scene.children(planet) { /**/ }
let parent = scene.parent(moon); // Option<Entity>
let n = scene.len();
```
### Extra components (it's a real ECS)
Entities are full `hecs` entities, so later stages attach their own components
(meshes, rigid bodies, …) alongside the `Node`/`Transform`:
```rust
scene.world_mut().insert_one(planet, /* e.g. */ 0u32).unwrap();
let value = scene.get::<u32>(planet); // Option<hecs::Ref<u32>>
```
Use `world()` for read-only queries and `world_mut()` for adding/removing
*non-hierarchy* components. Drive lifecycle and parenting through the `Scene`
methods so the hierarchy bookkeeping stays consistent — spawning or despawning
directly on the world bypasses it.
---
## `Node`
```rust
pub struct Node {
pub name: String, // display name; not required to be unique
pub enabled: bool, // honored by later systems, not by transform resolution
}
```
`Node::new(name)` builds an enabled node. `enabled` is a declaration of intent:
Stage 3 only stores and toggles it; rendering/physics/audio will skip disabled
subtrees in later stages. It deliberately does **not** affect
`world_transform`, which is purely geometric.
---
## Serialization
A scene round-trips through RON. Because `hecs::Entity` handles are not stable
across a save/load, the scene is flattened to an indexed node list in a
deterministic pre-order walk, so serialize → deserialize → serialize is
byte-for-byte stable.
```rust
let ron: String = scene.to_ron()?;
let restored = Scene::from_ron(&ron)?;
assert_eq!(ron, restored.to_ron()?); // identical
```
Corrupt input (out-of-range child indices, a node listed as both root and
child) is rejected with [`SceneError::Deserialize`].
---
## Errors
```rust
pub enum SceneError {
NoSuchEntity, // operation referenced a dead entity
WouldCycle, // reparent would make an entity its own ancestor
Serialize(String), // encoding to RON failed
Deserialize(String), // decoding failed or data was inconsistent
}
```
---
## Example
A complete, runnable tour lives in
[`examples/src/bin/scene_basic.rs`](../examples/src/bin/scene_basic.rs):
```sh
cargo run -p oxide-examples --bin scene_basic
```
It builds a sun/planet/moon hierarchy, prints local vs. world transforms,
reparents a node, round-trips through RON, and despawns with a detach policy.
## In the editor
`oxide-editor` renders a **Scene Hierarchy** panel (left) and an **Inspector**
(right) over the viewport, driving this same API: select a node, rename it,
toggle its `enabled` flag, reparent it via the Inspector's parent dropdown, and
add/delete nodes from the toolbar. The egui integration is editor-only — the
engine exposes a generic post-clear draw hook ([`App::render`]) and keeps egui
out of its own dependency tree.
[`App::render`]: ../engine/src/window/app.rs
## Design notes
- **Why hierarchy outside the ECS?** Storing `Parent`/`Children` as components
is idiomatic but makes ordered iteration and reparenting awkward (archetype
moves, borrow juggling) and gives no ordering guarantee. Keeping the tree in
the `Scene` yields deterministic child order — which serialization and the
editor both rely on — and O(1) link edits. The ECS still owns all entity
*data*.
- **World transforms are resolved, not stored.** There is no dirty-flag cache
yet; `world_transforms()` recomputes in one pass. A cache can be added later
behind the same API without changing callers.
- **Despawn policies** map onto the two things callers actually want: delete a
whole subtree, or remove one node and keep its children.
+332
View File
@@ -0,0 +1,332 @@
# Scripting (`oxide-script`, rhai) — Stage 10
The scripting module makes game logic live in **watched `.rhai` scripts** that
hot-reload while the editor runs. It is a feature-gated module built on
[`rhai`](https://rhai.rs) — an embeddable, sandboxed, Rust-friendly scripting
language — and plugs into the engine through the Stage-5 module system exactly
like [physics](physics.md): add [`ScriptModule`](#scriptmodule) to an `App` and
the [`Script`](#the-script-component) component becomes live.
> **Status.** This page tracks Stage 10 as it lands piece by piece. **Done so
> far:** the component data model, the script asset + `.rhai` loader, the engine
> wrapper, the module wiring, the per-frame lifecycle (`init` / `update(dt)`)
> driven by the [`ScriptHost`](#the-scripthost-lifecycle), the engine API
> (`Vec3` + ambient transform functions) scripts use to read/write their
> entity's `Transform`, **headless [live reload](#live-reload)** (edit a
> `.rhai` file → the running script recompiles, no restart), and **[editor
> integration](#editor-integration)** (Script is addable in the inspector; Play
> runs scripts and live-reload reaches a *playing* scene), and **[error/output
> surfacing](#errors-and-output-in-the-console)** (a paused script's error and
> its `print` output show in the editor Console panel), and a **[command
> terminal](#the-command-terminal)** in that panel, plus an **[interactive PTY
> terminal](#interactive-terminal-pty)** that runs shells / TUIs / AI-agent CLIs
> like `claude`. **Remaining for the stage:** a richer script API (spawn/despawn
> + component add/edit, beyond `Transform`).
## The model: ECS is the source of truth
As with physics, the **ECS owns the truth**. An entity opts into scripting with
one serializable, reflected component — [`Script`](#the-script-component) — that
carries only *authoring* inputs (which script, enabled or not). The script's
behaviour is **not** stored on the component:
- The source lives on disk as a `.rhai` file, loaded as a
[`ScriptAsset`](#scriptasset-the-loaded-source) (kept as plain text so a live
edit just recompiles).
- The compiled AST and any per-entity runtime state live in the host (a later
piece), keyed by entity.
Because the authored component carries no runtime state, **play-mode
snapshot/restore works for free**: Stop reverts the authored `Script` components
and the next Play recompiles fresh.
## The `Script` component
```rust
use oxide_engine::asset::AssetRef;
use oxide_script::{Script, ScriptAsset};
// Empty + disabled by default; `new` points at a source and enables it.
let script = Script::new(AssetRef::new(uid)); // uid from the asset database
assert!(script.enabled);
```
| Field | Type | Meaning |
|-------|------|---------|
| `source` | `AssetRef<ScriptAsset>` | Which `.rhai` script the entity runs. Empty until assigned. |
| `enabled` | `bool` | Whether the script runs; clear to suspend it without detaching. |
`source` is an [`AssetRef<T>`](assets.md), not a live `Handle<T>`, so it is
serializable and stable across runs; the inspector recognises the
`AssetRef<ScriptAsset>` spelling and offers a picker filtered to the `scripts/`
folder (`AssetKind::Script`). The component derives `Reflect`, so it is
dual-editable from the inspector, from scripts, and from external tools with no
per-type editor code.
## `ScriptAsset`: the loaded source
A `ScriptAsset` is just the script's source text plus a diagnostic name (the
file stem). It is deliberately inert — holding *source*, not behaviour — so the
same file can be recompiled on every live reload with no engine-specific data
baked into the asset cache.
```rust
use oxide_script::ScriptAsset;
let asset = ScriptAsset::from_source("spin", "let t = 0.0;");
assert_eq!(asset.name, "spin");
```
The `ScriptLoader` reads `.rhai` files; it is registered by `ScriptModule`, so
`assets.load::<ScriptAsset>("scripts/spin.rhai")` works once the module is added.
`.rhai` files map to the new `AssetKind::Script` (folder `scripts/`).
## The `ScriptEngine` wrapper
`ScriptEngine` owns one configured `rhai` interpreter the host reuses to compile
and run every script, so sandbox configuration lives in one place:
- `print` / `debug` output is routed to the `log` crate (so the editor console
can surface it rather than leaking to stdout);
- an operation cap (`set_max_operations`) turns a runaway loop into a **runtime
error** instead of hanging the editor.
```rust
use oxide_script::{ScriptAsset, ScriptEngine};
let engine = ScriptEngine::new();
let asset = ScriptAsset::from_source("ok", "let x = 1 + 2; print(x);");
let compiled = engine.compile(&asset)?; // -> CompiledScript (reusable AST)
engine.run(&compiled)?; // evaluate the top level
```
Errors are a `thiserror` enum, [`ScriptError`], with two variants that both name
the offending script so the console can attribute the failure:
- `ScriptError::Compile` — the source failed to parse/compile;
- `ScriptError::Runtime` — it compiled but raised an error (or hit the operation
cap) while running.
A runtime error is **returned, never panicked**, so the host can pause just that
one script rather than crash the editor — the foundation for Stage 10's
error-isolation goal.
## `ScriptModule`
```rust
use oxide_engine::app::App;
use oxide_script::ScriptModule;
let mut app = App::new();
app.add_module(ScriptModule);
assert!(app.has_module("script"));
assert!(app.types.is_registered("Script"));
```
`ScriptModule::build` registers the `Script` component type for reflection
(making it dual-editable and snapshot-captured), installs the `.rhai`
`ScriptLoader`, inserts the [`ScriptHost`](#the-scripthost-lifecycle), and adds
the `run_scripts` system on the `Update` schedule (after `FixedUpdate`, so
scripts observe post-physics poses). Removing the module drops everything it
contributed, as for any module.
## The `ScriptHost` (lifecycle)
`ScriptHost` is the scripting counterpart to physics' `PhysicsWorld`: a transient
`App` resource holding the **per-entity runtime state** — the compiled AST and a
persistent `rhai` `Scope` — that the authored `Script` component deliberately
does not. The `run_scripts` system drives it each frame:
1. find every entity with an **enabled** `Script` that names a source;
2. resolve each one's `.rhai` text through the `AssetDatabase` + `AssetServer`;
3. **(re)compile + `start`** any script that is new or whose source text changed
(this content check is the hook live reload builds on);
4. stage the entity's `Transform` into the engine, call `update(dt)`, and write
the (possibly mutated) transform back to the scene.
Because runtime state lives in the host keyed by entity — never on the component
— play-mode snapshot/restore is unaffected. A script that fails to compile or
raises a runtime error is **paused** (its error remembered, reported via
`ScriptHost::error_of`) rather than retried every frame or allowed to crash the
host — the start of Stage 10's error-isolation goal.
## Live reload
Editing a script while the app runs takes effect with **no restart**. The host
keeps each running script's `Handle<ScriptAsset>` alive, so the asset stays in
the server's cache. When the Stage-6 file watcher sees a `.rhai` file change it
calls
[`reload_changed_assets`](file-watching.md), which reruns the loader **in place**
on the live handle; the next frame the host reads the new source through that
handle, sees it differs from what it compiled, and recompiles + restarts that one
script. The entity keeps its current transform and the rest of the scene is
untouched.
`examples/script_spin` proves this headlessly: it spins an entity at 1 rad/s,
rewrites the script to 3 rad/s, reloads it the way the watcher does, and the spin
rate jumps mid-run while the orientation carries over.
```sh
cargo run -p oxide-examples --bin script_spin
```
> Wiring this into the editor's **play loop** (so editing a script in an external
> editor or via an AI agent updates a *playing* scene) is a following piece; the
> reload mechanism itself is done and tested.
### Lifecycle hooks
A script may define either or both of these functions; top-level statements run
once at start (a constructor for defining functions and one-shot setup):
```rhai
// scripts/spin.rhai — rotate this entity around Y at a constant rate.
let speed = 1.5; // top-level state, set once at start
fn init() { // optional: called once, after the top level
print("spin starting");
}
fn update(dt) { // optional: called every frame with the frame delta
rotate_y(dt * 1.5);
}
```
| Hook | When | Signature |
|------|------|-----------|
| top level | once, when the script (re)starts | statements at file scope |
| `init()` | once, right after the top level | `fn init()` |
| `update(dt)` | every frame | `fn update(dt)``dt` is seconds |
## Editor integration
Scripting plugs into the editor the same way physics does:
- **Add Component.** `register_builtin_types` registers `Script` as an *addable*
reflected component, so it appears in the inspector's Add Component menu and is
rendered generically from its fields. The `source` field is an
`AssetRef<ScriptAsset>`, which the inspector shows as an asset picker filtered
to the project's `scripts/` folder (`AssetKind::Script`).
- **Play loop.** When Play starts, the editor's play `App` adds `ScriptModule`
alongside `PhysicsModule`, **shares the editor's `AssetServer`**, and is handed
a snapshot of the project `AssetDatabase`. Sharing the server is what lets the
file watcher's in-place reloads — which target the editor server — reach a
**playing** scene: edit a `.rhai` file (by hand, an external editor, or an AI
agent) and the running script recompiles without leaving Play.
- **Snapshot/restore.** Because `Script` is a reflected component, the play-mode
snapshot captures it; a script attached or detached *during* play is reverted
on Stop, like any other component.
## Errors and output in the Console
A script that fails to compile or raises a runtime error is **paused** — it stops
running but the editor stays alive (the error is caught, never panicked). The
host logs the failure once via `log::warn!(target: "oxide_script", …)` and
remembers it (`ScriptHost::error_of`), so it is not retried until the source
changes.
The editor's **Console panel** captures the `log` stream into a ring buffer and
renders it, coloured by severity. Because script `print`/`debug` and the
"script paused: …" errors all flow through `log` under the `oxide_script` target,
they appear in the Console automatically — so you see a script's output and its
failures without leaving the editor.
## The command terminal
The Console panel doubles as a **command terminal**: a `$` prompt runs a shell
command (`sh -c`) with the working directory set to the open project's root, and
its stdout/stderr stream back into the same panel line by line as they arrive
(stdout at info, stderr at warn, plus the echoed command and an exit-status
line). A long-running command — a build, a watcher, an AI-agent CLI — streams
rather than blocking the editor: reader threads push each line to the shared
console buffer and the panel re-renders next frame.
This is the surface for non-interactive dev tools. Running arbitrary commands
from the editor is intentional (a developer tool, compiled out of an exported
game).
### Interactive terminal (PTY)
The command console pipes output and can't run programs that need a real
terminal. The separate **Terminal panel** can: it opens a pseudo-terminal with
[`portable-pty`] (Linux now, Windows later), parses the program's byte stream
with [`vt100`] into a screen grid, renders that grid in egui, and routes
keystrokes back — so it runs **interactive / full-screen programs**: a shell, a
REPL, `vim`, or an **AI-agent CLI like `claude`**. Click *Shell* to start your
`$SHELL` in the project directory, then run whatever you need inside it
(`claude`, an editor, a build watcher). Tab, the arrow keys, and Escape are
delivered to the program (not used for egui focus navigation) while the panel is
focused, so completion, history, and full-screen apps work. Sessions are
**tabbed**`+ Shell` opens another, each tab has a close button, and a tab
**auto-closes when its program exits** (type `exit` and the tab disappears).
This is what hosts agents that edit the watched scripts live — their edits flow
back through [live reload](#live-reload).
The two pieces compose: use the **Console** for builds/git/log output, the
**Terminal** for interactive sessions.
[`portable-pty`]: https://docs.rs/portable-pty
[`vt100`]: https://docs.rs/vt100
## The engine API scripts call
A script does not get a raw ECS pointer. The host stages the active entity's
`Transform` into a shared context before each call; the script reads and mutates
it through **ambient functions**, and the host writes the result back. This is
the `Transform` half of dual-editability: a script and the inspector edit the
**same** transform.
| Function | Effect |
|----------|--------|
| `position() -> Vec3` | the entity's local translation |
| `set_position(Vec3)` | set the translation |
| `translate(Vec3)` / `translate(x, y, z)` | add to the translation |
| `scale() -> Vec3` / `set_scale(Vec3)` | get/set the local scale |
| `rotate_x/rotate_y/rotate_z(radians)` | spin about an axis (accumulates) |
| `dt() -> f32` | the current frame delta (also passed to `update`) |
The `Vec3` type is registered with `vec3(x, y, z)` / `vec3()`, `.x`/`.y`/`.z`
get/set, `+ - *` (and scalar `*`), `length()`, `normalize()`, and `to_string()`.
The `rhai` float type is configured to `f32` (the `f32_float` feature), so engine
math values bridge into scripts with no casts.
### Spawning entities and editing components
Beyond its own transform, a script can mutate the whole scene graph: spawn and
despawn entities, and add, edit, or remove **any registered component** on any
entity. The same "stage in, read back out" discipline applies — a script cannot
borrow the ECS directly (the `rhai` functions must be `Send + Sync`), so each of
these calls **buffers a command** that the host drains and applies against the
scene + reflection registry after the script returns. Component edits go through
**RON**, so a script authors data exactly like the inspector or an AI agent does
— the same dual-editable representation.
| Function | Effect |
|----------|--------|
| `entity() -> Entity` | the entity this script runs on |
| `spawn_entity() -> Entity` / `spawn_entity(name)` | create a root entity, returns a handle usable immediately |
| `despawn(Entity)` | remove an entity (and its subtree) |
| `add_component(Entity, type_name)` | add a default-constructed component if the type is *addable* and absent |
| `set_component(Entity, type_name, ron)` | insert or replace a component from its RON form |
| `remove_component(Entity, type_name)` | remove the named component if present |
`spawn_entity` returns a **provisional** `Entity` handle: the entity does not
exist in the ECS yet, but the script can configure it in the same frame
(`set_component(e, …)`, `despawn(e)`) — the host resolves the provisional id to
the real entity when it applies the buffered commands, in issue order. (`spawn`
is a reserved word in `rhai`, hence the longer name.)
`type_name` is the name the component was registered under (e.g. `"MeshRenderer"`,
`"RigidBody"`, or your own `register_type::<T>("…")`). A command that names an
unregistered type, or whose RON fails to parse, is logged to the Console and
skipped — one bad call never aborts the rest or crashes the host.
```rust
// Spawn a pickup and configure it in one frame:
fn init() {
let pickup = spawn_entity("Coin");
set_component(pickup, "MeshRenderer", "(mesh: Some(\"coin\"), ...)");
}
```
[`ScriptError`]: #the-scriptengine-wrapper
+70
View File
@@ -0,0 +1,70 @@
# Settings & Preferences Framework
`oxide_engine::settings` is the engine's unified, serialized configuration store.
The engine, the editor, and every module register typed **sections**; the
framework persists them all to RON and restores them — without any central code
knowing the sections' shapes. It is the backbone of the editor's Preferences
window and of per-module settings.
## Sections
A section is any plain `serde`-serializable struct with a `Default`. Register it
once under a name and the store owns a typed instance:
```rust
use oxide_engine::settings::Settings;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Default)]
struct EditorPrefs { theme: String, grid: bool }
let mut settings = Settings::new();
settings.register::<EditorPrefs>("editor");
// Typed read/write.
settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
assert_eq!(settings.get::<EditorPrefs>("editor").unwrap().theme, "dark");
```
`set` replaces a section's value (only if the registered type matches), and
`reset` returns it to `Default`. Accessing a section as the wrong type returns
`None` rather than panicking.
## Persisting
Every section serializes to a `name → RON` map via `export`, and `import` loads
matching sections back. This map is exactly the shape a
[`Project`](projects.md) stores, so per-project settings round-trip through the
project file:
```rust
# use oxide_engine::settings::Settings;
# use serde::{Serialize, Deserialize};
# #[derive(Serialize, Deserialize, Default)] struct EditorPrefs { theme: String }
# let mut settings = Settings::new();
# settings.register::<EditorPrefs>("editor");
let saved = settings.export(); // BTreeMap<String, String>
let mut restored = Settings::new();
restored.register::<EditorPrefs>("editor");
restored.import(&saved); // matching sections restored
```
`import` is deliberately lenient: an **unknown** section (e.g. one owned by a
disabled module) is ignored, and a **malformed** section is skipped, leaving its
current value. This means a project saved with a module enabled still opens
cleanly with that module disabled, and vice-versa.
## How the layers fit together
| Scope | Lives where | Persisted to |
|-------|-------------|--------------|
| Engine preferences (render/quality defaults) | a `Settings` section | global prefs file / project |
| Editor preferences (theme, layout, shortcuts) | a `Settings` section | global editor prefs file |
| Per-module settings | each module registers a section | the [project](projects.md) it's enabled in |
`Project` stores the per-project subset (`set_settings_section` /
`settings_section` hold the same RON blobs `export`/`import` produce). The editor
keeps its global preferences in a separate file using the same `Settings` API.
[`Settings`]: ../engine/src/settings.rs
+860
View File
@@ -0,0 +1,860 @@
# UI System
The `oxide_engine::ui` module is the engine's **in-game** UI system — what an
exported game uses to draw menus, HUDs, and tools. It is intentionally
separate from the editor's `egui` (which stays editor-only): a shipped game
cannot link `egui`, so the runtime owns its own widget tree, lays it out,
batches it through the Stage-5 render pipeline, and routes input through the
Stage-7 model.
Stage 8 ships in pieces. This document covers what is live today and tells
you where the rest is going.
## What's live today
- **Piece 1 — widget tree + layout** (data structures, three layout modes,
pure-logic layout function). See [below](#whats-in-piece-1--widget-tree--layout).
- **Piece 2 — styling & theming** (per-widget visual overrides, named-style
themes, RON cascade). See [below](#whats-in-piece-2--styling--theming).
- **Piece 3 — text shaping & glyph atlas** (TTF loading via `ab_glyph`,
shelf-packed R8 atlas, multi-font line wrapping with alignment + DPI
scaling). See [below](#whats-in-piece-3--text-shaping--glyph-atlas).
- **Piece 4a — screen-space overlay render pass** (`paint` turns a laid-out
tree into draw commands; `UiOverlayPass` batches them through wgpu with one
R8 atlas and one alpha-blended pipeline). See [below](#whats-in-piece-4a--screen-space-overlay-render-pass).
- **Piece 4b — world-space UI panels** (`UiPanel` carries a `Widget` tree +
pixel/world sizes; `UiBatch::world_space(...)` composes the MVP that
places the UI on a 3D quad through a perspective camera). See [below](#whats-in-piece-4b--world-space-ui-panels).
- **Piece 5 — input routing** (`Router` walks the `LayoutTree` against the
Stage-7 `InputState`, tracks hover / press / focus per widget, and emits
events plus capture flags the host uses to decide whether the game also
receives the input). See [below](#whats-in-piece-5--input-routing).
- **Piece 6 — events + data binding** (immediate-mode queries on
`RouterFrame``clicked_left("play")` etc. — plus typed `WidgetValue`s
on the tree so game state and widget state round-trip each frame). See
[below](#whats-in-piece-6--events--data-binding).
- **Piece 7 — `examples/ui_menu`** (runnable main menu + settings panel
built entirely from the Stage-8 stack: themed buttons, a draggable
volume slider, a clickable invert-Y checkbox, Back/Quit navigation).
Run with `cargo run -p oxide-examples --bin ui_menu`.
- **Piece 8 — `examples/ui_hud`** (a game HUD composited on top of a live
3D scene: the Stage-4 `ForwardPass` renders the spinning cube/sphere/
plane, then a screen-space `UiOverlayPass` draws corner-anchored HP/Ammo
chips, a minimap stand-in with an orbiting dot, and a centre crosshair —
with animated digits that demonstrate the glyph-atlas cache reaching
steady state). Run with `cargo run -p oxide-examples --bin ui_hud`. See
[below](#whats-in-piece-8--examplesui_hud).
- **Editor UI canvas** (Stage 8.5 piece 7) — the editor's **UI Canvas** panel
authors a `UiPanel` document visually: a widget-tree view (positional
[`WidgetPath`](../engine/src/ui/widget.rs) addressing), an Add palette
(Leaf/Row/Column/Grid/Anchor), a scaled live preview, and a property panel
(id, text, colors, font size, **font-asset picker**, layout sizing). Edits are
undoable and the document saves as a `ui/` asset — the same RON the runtime
loads. The picker writes [`VisualStyle::font_asset`](../engine/src/ui/visual.rs),
resolved through the [asset database](assets.md).
## What's in piece 1 — widget tree + layout
Piece 1 is pure-logic: data structures + a deterministic layout function. No
GPU, no input, no async. Every test runs headlessly.
- **`Widget`** — one node in a tree. Holds an [`id`](#widget-ids), a
[`LayoutStyle`](#layoutstyle), and a [`WidgetKind`](#widgetkinds).
- **`WidgetKind`** — what the node is:
- `Leaf { intrinsic: Vec2 }` — childless node sized by an intrinsic logical
extent. Interactive widgets (label, button, image, slider, …) layer on
top of this in later pieces.
- `Stack(Stack)` — row or column container with a per-stack `gap`,
`direction`, and `main_align`.
- `Grid(Grid)` — equal-cell `cols × rows` container with a `gap: Vec2`.
- `Anchor(AnchorGroup)` — container that positions each child via the
**child's** own [`Anchor`](#anchor).
- **`LayoutStyle`** — sizing, padding, margin, alignment, and (for anchor
children) the anchor itself. The same flat struct on every widget.
- **`layout(root, viewport, scale) -> LayoutTree`** — the layout function.
Returns a `LayoutTree` of `LayoutNode`s (one per widget, root at index 0)
with each node's resolved `rect`, `content_rect` (padding-inset), and the
indices of its direct children.
The whole module lives under
[`engine/src/ui/`](../engine/src/ui/) and is re-exported through the engine
prelude under disambiguated names (`UiSizing`, `UiAnchor`, `Widget`, …) so it
doesn't collide with the Stage-1 math types.
## Building a widget tree
The `Widget::row()`, `Widget::column()`, `Widget::grid(cols, rows)`,
`Widget::anchor()`, and `Widget::leaf(intrinsic)` constructors plus the
`with_*` builder methods produce trees declaratively. Builder methods that
only make sense on certain kinds (`with_gap` on a stack, `with_grid_gap` on a
grid, `with_child` on any container) panic with a clear message when called
on the wrong kind — catching author mistakes during construction instead of
producing a silently misshapen UI at layout time.
```rust
use oxide_engine::math::Vec2;
use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
let toolbar = Widget::row()
.with_id("toolbar")
.with_gap(8.0)
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Fixed(32.0),
padding: Insets::all(4.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
.with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("edit"));
```
## Sizing
`Sizing` controls how a widget asks to be sized along one axis.
| Variant | Behavior |
|---------|----------|
| `Fixed(f32)` | Fixed logical size; multiplied by the layout scale factor. |
| `Grow(f32)` | Take a share of the parent's leftover space, weighted by `f32`. Two siblings with `Grow(1.0)` split evenly; `Grow(2.0)` next to `Grow(1.0)` takes 2/3. A non-positive weight contributes nothing. |
| `FitContent` (default) | Fit the widget's intrinsic content size — leaves use their `intrinsic`, containers use the recursive content extent. |
The defaults of `FitContent × FitContent` are intentional: leaves are sized
by what they contain, containers are sized by what they wrap. A root widget
that wants to **fill the viewport** must opt in with
`Sizing::Grow(_)` on both axes (or set `Fixed` extents) — the layout function
makes no special root case.
## Padding, margin, alignment
- **`padding`** shrinks a widget's `content_rect`, the area inside which
children are arranged. Multiplied by the scale factor.
- **`margin`** reserves space *outside* the widget's rect, so siblings don't
touch it. In a stack, margin is added to the child's main-axis footprint
before grow accounting.
- **`align_horizontal` / `align_vertical`** position a widget within its
parent's slot when the widget's resolved size is **smaller** than the slot.
In a stack, cross-axis alignment lets a short child dock to the top,
middle, or bottom of its row. (The stack-level `main_align` does the
analogous thing on the main axis when there's no `Grow` child to absorb
leftover space.)
## Layout modes
### Stack (`StackDirection::Row` / `Column`)
1. Allocate each child's **main-axis** size:
- `Fixed(v)``v * scale`,
- `FitContent` → recursive intrinsic measurement,
- `Grow(w)` → reserved (zero first), then assigned a share of leftover
space proportional to `w`.
2. **Cross-axis** sizing happens during the child's own `arrange_in_slot`
pass: `Grow` fills the parent's cross extent; the other variants leave
space the child's `align_*` consumes.
3. With no `Grow` child, the stack's `main_align` (Start / Center / End)
positions the children's combined footprint inside the content rect.
### Grid
Equal-cell `cols × rows` layout. Cell size is computed from the parent's
content rect after subtracting `(cols - 1) * gap.x` and `(rows - 1) * gap.y`.
Children fill cells left-to-right, top-to-bottom; extras past `cols * rows`
are ignored. Within a cell the child's own `align_*` and sizing decide how it
positions itself — `Grow` fills the cell, `Fixed`/`FitContent` aligns inside
it.
More flexible grids (auto-sized rows/columns, spans) are a follow-up; the
equal-cell case covers the Stage-7 bindings preferences page and the Stage-8
settings examples.
### Anchor
Each child specifies its own `Anchor` in `LayoutStyle::anchor`. The anchor is
two normalized points in `[0, 1]²` (the anchor rectangle) plus per-corner
offsets in logical pixels:
```text
rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
```
The Unity/Godot convention applies: the anchor is **authoritative**. An
anchor child's `width`, `height`, `margin`, and `align_*` are ignored along
the axes the anchor constrains; padding still applies (it's an
inside-the-rect concern). The
`Anchor::FILL`, `Anchor::TOP`, `Anchor::TOP_LEFT`, `Anchor::BOTTOM_RIGHT`, …
constants cover the common cases, and `Anchor::between(min, max)` +
`with_offsets(min, max)` is the escape hatch.
## DPI
All linear inputs (sizing, padding, margin, gap, anchor offsets) are in
**logical pixels** and multiplied by the `scale` factor passed to
[`layout`]. The widget tree is DPI-independent; the layout call is where the
display's scale factor enters. The same widget tree laid out at `scale=1.0`
inside a 800 × 600 viewport and at `scale=2.0` inside a 1600 × 1200 viewport
produces identically *proportioned* rects, with every dimension doubled —
verified by an integration test.
## Widget ids and lookups
`WidgetId(pub String)` is the author-facing identifier. UI documents ship
their string ids straight through RON (`"play"`, `"volume-slider"`), so a
visual editor, a hand-edited file, and game code all refer to the same
widget. The empty id (`""`) is the default and means "anonymous"; multiple
anonymous widgets are allowed and `LayoutTree::find` rejects lookups by empty
id.
`LayoutTree::find(id)` is a linear scan — fine for the dozens-of-widgets
trees Stage 8 currently targets; a hash-map index can be added if a profile
ever says it's hot.
## RON dual-edit
Every type in the module derives `Serialize + Deserialize` and round-trips
through RON. `Widget::to_ron()` produces the pretty-printed canonical form
the editor's UI canvas saves and the runtime loads; `Widget::from_ron(text)`
parses it. The Stage-8 integration suite verifies that the round-trip
**preserves layout** — the laid-out trees match — so an external editor or AI
agent can edit the same file the runtime loads.
## What's in piece 2 — styling & theming
Visual styling is intentionally **orthogonal** to layout — layout decides
where a widget is; visual styling decides what it looks like. Adding a
`VisualStyle` or `theme_style` to a widget never changes its laid-out rect.
The integration suite verifies this with a paired `layout()` call before and
after styling.
The data:
- **`VisualStyle`** — a flat struct of `Option<T>` fields: `background`,
`foreground`, `border` (color + width), `corner_radius`, `font`, and
`font_size`. `None` means *inherit*; `Some` means *override*. Every field
serializes via `skip_serializing_if = "Option::is_none"`, so an empty
visual style vanishes from RON entirely.
- **`Theme`** — `default: VisualStyle` plus `styles: BTreeMap<String,
VisualStyle>`. The `BTreeMap` (not `HashMap`) gives deterministic RON
output, important for diff-friendly UI documents and reproducible test
snapshots.
- **`Widget`** gains two fields: `visual: VisualStyle` (per-instance
overrides) and `theme_style: Option<String>` (opt-in name into the
theme's named map).
The cascade — implemented by `Theme::resolve(style_ref, override_with)` and
exposed on the widget as `Widget::resolve_visual(&theme)`:
1. Start with `theme.default`.
2. If the widget specifies `theme_style: Some(name)` and the theme has a
matching entry, merge it on top (a missing name is treated as "no
contribution", not an error).
3. Merge the widget's per-instance `visual` on top.
Each merge is field-by-field via `VisualStyle::merged(self, override_with)`:
right-hand `Some` wins, otherwise the left-hand value is kept. The same
primitive will drive runtime state overlays in piece 5 (hover, focus,
press).
`FontRef` carries `family`, `weight: FontWeight`, and `italic: bool`. The
descriptor stores **names**, not paths: portable across machines, and the
runtime (piece 3) is free to pick the platform's best match. `FontWeight`
exposes `opentype_value()` returning the OpenType 100900 weight scale.
`VisualStyle` also has a `font_asset: Option<AssetRef<Font>>` (Stage 8.5 piece
7): a reference to a **specific project font asset** under `assets/fonts/`,
chosen in the editor's UI canvas from the asset browser. When set it takes
precedence over the `font` descriptor — the renderer resolves the [`AssetRef`]
to a loaded face through the [asset database](assets.md) (a default-registered
`FontLoader` makes `.ttf`/`.otf` loadable via the `AssetServer`). `None` falls
back to the descriptor / theme path. This is the engine's first `AssetRef<T>`
field and the asset-picker's end-to-end target.
[`AssetRef`]: ../engine/src/asset/database.rs
### Quick example
```rust
use oxide_engine::math::{Color, Vec2};
use oxide_engine::ui::{Border, FontRef, Theme, VisualStyle, Widget};
let theme = Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
font: Some(FontRef::regular("Inter")),
font_size: Some(14.0),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
corner_radius: Some(4.0),
..VisualStyle::EMPTY
},
);
let play = Widget::leaf(Vec2::new(80.0, 24.0))
.with_id("play")
.with_theme_style("button")
.with_visual(VisualStyle {
background: Some(Color::rgb(0.2, 0.4, 0.8)), // primary-button accent
foreground: Some(Color::WHITE),
..VisualStyle::EMPTY
});
let resolved = play.resolve_visual(&theme);
assert_eq!(resolved.foreground, Some(Color::WHITE)); // per-instance wins
assert_eq!(resolved.corner_radius, Some(4.0)); // inherited from "button"
assert_eq!(resolved.font, Some(FontRef::regular("Inter"))); // inherited from default
```
### RON dual-edit
`Theme::to_ron` / `Theme::from_ron` round-trip themes through pretty-printed
RON, matching `Widget::to_ron` from piece 1. `BTreeMap`-ordered output keeps
named styles alphabetised so diffs are stable. Empty fields (`None` options,
empty maps, `FontWeight::Regular`, `italic: false`) skip serializing — the
default form of any of these structs is `()` in RON.
## What's in piece 3 — text shaping & glyph atlas
The text subsystem lives at `oxide_engine::ui::text` and splits into three
sub-modules that compose, but each is testable on its own:
- **`font`** — owns `Font` (a thin wrapper around `ab_glyph::FontVec`),
`FontId`, and `FontStore`. `Font::rasterize(glyph, size_px)` returns a
`RasterizedGlyph` with an alpha mask + per-glyph bearings + advance.
`FontStore::insert_with_descriptor(FontRef, Font)` indexes a font under a
piece-2 `FontRef`, so a theme's `font: Some(FontRef::bold("Inter"))`
resolves to a `FontId` the shaper can use.
- **`atlas`** — `GlyphAtlas::new(width, height)` allocates a single R8
(alpha-only) buffer; `get_or_rasterize(GlyphKey, &FontStore)` returns the
glyph's `AtlasEntry` (UV rect + size + bearing + advance), rasterizing
and packing on first miss and serving the cache forever after. The
packer is a **best-fit shelf packer** — simple, deterministic, and
near-optimal density for the typically-uniform glyph heights of one font
at one size. The `dirty()` flag tells the piece-4 render pass when the
texture needs re-upload.
- **`shape`** — `shape(text, style, &params, &fonts)` turns a string into
a `ShapedText { lines, size }` of positioned `ShapedGlyph`s. Each glyph
carries a `GlyphKey` the renderer feeds back into the atlas, and a
`position` at the **baseline** (not the top-left). Algorithm:
greedy line-break at ASCII whitespace, multi-font runs supported via
`shape_runs(&[TextRun])`, alignment within `max_width` (Left / Center /
Right), DPI scaling via `ShapeParams::scale`.
### The atlas is the cache
`GlyphAtlas` keys entries by `(FontId, GlyphId, size_px rounded to nearest
integer)`. Every glyph is rasterized **exactly once** per (font, glyph,
size) triple — a HUD that repaints `"HP: 1234 / 1500"` every frame
rasterizes the ten ASCII characters one time at startup and then runs
purely on textured quads. The integration suite verifies this:
`shaped_hud_text_is_cached_after_one_frame` shapes a three-line HUD,
walks every glyph through the atlas twice, and asserts the atlas's
`dirty` flag stays false on the second pass — i.e., zero new
rasterizations. The library choice (ab_glyph vs fontdue) only affects
the one-time miss cost, not steady-state.
### Quick example
```no_run
use oxide_engine::math::Vec2;
use oxide_engine::ui::text::{
shape, Font, FontStore, GlyphAtlas, ShapeParams, TextAlign, TextStyle,
};
let mut fonts = FontStore::new();
let id = fonts.insert(Font::from_path("/usr/share/fonts/.../Inter-Regular.ttf").unwrap());
let style = TextStyle { font: id, size_px: 14.0 };
let params = ShapeParams {
max_width: Some(300.0),
align: TextAlign::Center,
line_height: 1.4,
scale: 1.0,
};
let shaped = shape("Press F to pay respects", style, &params, &fonts);
let mut atlas = GlyphAtlas::new(1024, 1024);
for line in &shaped.lines {
for glyph in &line.glyphs {
// Render with the atlas's bearing offset; this is exactly the
// call piece 4's overlay pass will make per glyph per frame.
if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
let quad_top_left: Vec2 = glyph.position + entry.bearing;
let _quad_size: Vec2 = entry.size_px;
let _ = (quad_top_left, entry.uv_min, entry.uv_max);
}
}
}
```
### Limitations (deliberate, scoped to piece 3)
- One glyph per `char` — no ligatures, no combining marks, no complex-
script shaping (Arabic, Devanagari, Thai). The data path is ready for
a future `rustybuzz`-shaped intermediate; the current shaper just
doesn't invoke one.
- No BiDi or RTL — text flows left-to-right.
- No hyphenation or character-level break inside an over-wide word.
- ASCII whitespace only (`\t` and `\r` are treated as spaces).
- No bold/italic synthesis — each face is a separately-loaded `Font`.
### Font choice
The engine doesn't bundle a font; piece-3 tests use whichever sans-serif
they find on `/usr/share/fonts/` (or `/System/Library/Fonts` on macOS) via
`common_system_font_paths()`, skipping with `eprintln!("SKIP: …")` when no
candidate is present. The default UI font shipped with examples is a
piece-7 decision.
### Why ab_glyph
`ab_glyph` is a TTF parser + rasterizer only. It does not do layout —
which is fine because the shaper above already owns that. With
`fontdue` we would have gotten line wrapping for free at the cost of
living inside a fixed layout model; with `ab_glyph` we own every line-
break, kerning, and alignment decision. That control buys us a clean
path to richer features later: rich-text markup, per-character
animation, in-canvas editor caret positioning, and **SDF font
rendering** — a future follow-up where each glyph is rasterized once
as a signed-distance field and the shader scales it to any size for
free. SDF is on the Stage-8 backlog in [PLAN.md](../PLAN.md); it would
slot in beside `ab_glyph` without rewriting the shaper.
## What's in piece 4a — screen-space overlay render pass
Piece 4 splits the GPU work into two commits — **4a (screen-space, this
piece)** and **4b (world-space UI panels in 3D)**. Both share one render
pass, one shader, one R8 glyph atlas. The split is purely for review
size; the same `UiOverlayPass` handles both modes via per-batch MVP
matrices.
Two new pieces, both pure-CPU but the second one talks to wgpu:
- **`oxide_engine::ui::paint`** — `paint(&Widget, &LayoutTree, &Theme,
&FontStore, scale) -> PaintedFrame`. Walks the laid-out tree in
parent-then-children order; for each node, resolves the cascaded
[`VisualStyle`](#whats-in-piece-2--styling--theming) under the theme,
emits one `DrawCommand::Quad` if a background was resolved, and shapes
the widget's `text: Option<String>` inside its `content_rect` to emit
one `DrawCommand::Glyph` per laid-out glyph. Pure-logic; tests run
without a GPU and most without a font.
- **`oxide_engine::render::UiOverlayPass`** — implements
[`RenderPass`](render-pipeline.md) and slots into the Stage-5 pipeline
*after* the `ForwardPass`. Consumes `Vec<UiBatch>` per frame; each batch
pairs an MVP matrix with a `PaintedFrame`. For piece 4a the host builds
one batch with `UiBatch::screen_space(painted, target_size)` — an
orthographic projection from window pixels to NDC with y-down (origin at
the top-left).
### Vertex format and shader
One vertex format, one fragment path:
```text
struct UiVertex { position: vec2, uv: vec2, color: vec4 } // 32 bytes
```
The shader (`engine/src/render/shaders/ui.wgsl`) discriminates "solid quad
vs. glyph quad" by a sentinel UV: `uv.x < 0.0` skips the atlas sample. So
a solid red rectangle and a glyph from "Inter" pass through identical
pipeline state and live in the same vertex buffer — no state changes per
primitive, no separate textures. Alpha-blending is on; UI never reads
depth (it overlays).
### Atlas lifecycle
Each frame's `run`:
1. Walk every glyph in every batch, calling
`GlyphAtlas::get_or_rasterize(key, &fonts)` to ensure the entry is
cached. Misses rasterize once; hits do nothing.
2. If the atlas's `dirty` flag is set, re-upload the whole R8 buffer to
the GPU texture and clear the flag. Re-uploading the whole atlas (vs.
tracking dirty sub-rects) keeps the code simple; the buffer is small
(1 MB at 1024×1024) so this is fine. A dirty-region upload is a
straightforward follow-up if a profile says it's hot.
3. For each batch: serialize draw commands into vertices, write the MVP
uniform, set the viewport from `FrameContext::resolved_viewport()`,
and submit one draw call.
### Test strategy
The piece-4 tests live in three places:
- `engine/src/ui/paint.rs` — 5 lib tests verify the CPU paint logic: a
solid widget emits one quad at its rect, a text widget emits one glyph
command per visible char at the same baseline, layered widgets draw
parent-before-child, etc. No GPU required.
- `engine/src/render/ui_pass.rs` — 4 headless GPU pixel-readback tests:
a 20×20 red quad shows red at its centre and clear-color outside; an
empty batch list is a no-op; two quads in one batch both render to
their respective rects; the vertex buffer grows when a batch exceeds
the initial 4096-vertex capacity.
- `tests/src/lib.rs` mod stage8 — 1 integration test runs the whole
pipeline: `Widget` → `layout` → `paint` → `UiOverlayPass::run` →
pixel-readback, then asserts the centred 48×48 red panel is red in the
middle and clear-color in the gutter.
Both lib and integration GPU tests skip with `eprintln!("SKIP: ...")` if
no adapter is available, matching the Stage-4 pattern.
### Wiring it into an app
```rust,no_run
use oxide_engine::math::Color;
use oxide_engine::render::{RenderPipeline, UiBatch, UiOverlayPass};
use oxide_engine::ui::{layout, paint, FontStore, Theme, Widget};
# fn build_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) {
let mut pipeline = RenderPipeline::forward(device, format);
let ui_pass = UiOverlayPass::new(device, format);
pipeline.add_pass("ui", ui_pass);
# }
# fn each_frame(
# ui_pass: &mut UiOverlayPass,
# document: &Widget,
# theme: &Theme,
# fonts: &FontStore,
# viewport: oxide_engine::math::Rect,
# target_size: (u32, u32),
# ) {
let tree = layout(document, viewport, 1.0);
let painted = paint(document, &tree, theme, fonts, 1.0);
ui_pass.set_batches(vec![UiBatch::screen_space(painted, target_size)]);
// pipeline.render(&mut frame); — at next frame.
# }
```
## What's in piece 4b — world-space UI panels
`oxide_engine::ui::UiPanel` is a pure-data holder: a `Widget` tree plus two
sizes — `pixel_size` (the resolution the UI is laid out at) and
`world_size` (the panel's physical dimensions in world units). It does
*not* own the panel's `Transform`; that lives on the entity that hosts
the panel (eventually a hecs component), so the same panel can be
duplicated across many entities with different placements.
`UiBatch::world_space(painted, pixel_size, world_size, &panel_transform,
view_projection)` composes a single MVP that the existing piece-4a pass
uses unchanged:
```text
mvp = view_projection
* panel_transform // world placement
* scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (y-flip)
* translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
```
A pixel at `(0, 0)` in the painted frame lands at the panel's top-left
corner in world space; a pixel at `pixel_size` lands at the bottom-right.
Same pipeline, same shader, same atlas — only the MVP differs.
`UiPanel::build_batch(theme, fonts, &panel_transform, view_projection)`
is the convenience that lays out + paints + builds the batch in one call.
Hosts that want finer control compose the same three steps by hand.
### Overlay semantics
World-space panels in piece 4b render as **overlays**: no depth test, no
depth write — they draw on top of whatever's in the colour target. That
keeps the implementation simple and matches the common "always-visible"
use case (player nameplates, mission markers, editor canvas previews).
A future **depth-aware mode** (where a panel behind a wall is properly
hidden) is in PLAN.md's Stage-8 backlog and slots in by attaching the
depth target to a second pass of the same pipeline.
### Tests
- 4 lib tests on `UiPanel`: `build_batch` returns `None` on zero
`pixel_size`, succeeds on a valid panel, the panel round-trips through
RON for dual-edit, and the identity-MVP sanity check maps pixel
`(0, 0)` to world `(-world.x/2, +world.y/2)` (verifying the y-flip).
- 1 GPU pixel-readback lib test (`world_space_panel_renders_inside_its_projected_region`):
a 2 m × 2 m red panel at the origin under a 60° camera 3 m away,
asserts the framebuffer centre is red and corners stay clear.
- 1 integration test (`ui_panel_in_3d_renders_under_perspective_camera`):
exercises the full `UiPanel::build_batch` → `UiOverlayPass` path end
to end with a real perspective camera and a pixel-readback assertion.
### Quick example
```rust,no_run
use oxide_engine::math::{Color, Transform, Vec2, Vec3};
use oxide_engine::render::{Camera, UiBatch, UiOverlayPass};
use oxide_engine::ui::{FontStore, Theme, UiPanel, VisualStyle, Widget};
# fn each_frame(pass: &mut UiOverlayPass, panel: &UiPanel) {
let camera = Camera::perspective(60_f32.to_radians(), 0.1, 100.0);
let view_transform = Transform::looking_at(Vec3::new(0.0, 1.5, 4.0), Vec3::ZERO, Vec3::Y);
let view_projection = camera.view_projection(16.0 / 9.0, &view_transform);
// Where the panel sits in the world. Treat as if it were a Transform
// component on the entity hosting the panel.
let panel_transform = Transform::default();
let theme = Theme::new();
let fonts = FontStore::new();
let batch = panel
.build_batch(&theme, &fonts, &panel_transform, view_projection)
.expect("valid panel");
pass.set_batches(vec![batch]);
// pipeline.render(&mut frame); — at next frame.
# }
```
## What's in piece 5 — input routing
The UI must consume input *before* the game (PLAN.md): clicking a button
shouldn't also fire the game action bound to the same mouse button.
`oxide_engine::ui::routing` gives the host one object that does this
end-to-end:
```rust,no_run
use oxide_engine::prelude::*;
use oxide_engine::ui::Router;
# fn each_frame(router: &mut Router, tree: &UiLayoutTree, input: &InputState) {
let frame = router.process(tree, input);
if !frame.captured_mouse {
// game receives mouse this frame
}
if !frame.captured_keyboard {
// game receives keys this frame
}
for event in &frame.events {
// piece 6 will dispatch each event to the matching widget's callback
}
# }
```
### Hit-test
`hit_test(&LayoutTree, point) -> Option<&LayoutNode>` walks the laid-out
nodes in **reverse order** — the same order as paint (parents-then-
children, earlier siblings before later ones), so the topmost-drawn
widget is the first one tested. Anonymous widgets
(`WidgetId::default()`) are skipped so a decorative container doesn't
block clicks reaching the button inside it.
### State machine
The `Router` persists three pieces of state across frames:
- **hovered** — recomputed each frame from the cursor + hit-test.
- **focused** — set when the cursor presses over a widget; cleared when
the cursor presses outside any widget. Survives subsequent hover
changes so a focused text input keeps focus while the cursor moves.
- **pending presses** — per-button, the widget that received the
most-recent unreleased press. A press → release on the **same**
widget emits `Clicked`. Drag-off then release cancels the click.
### Events
`RouterFrame.events: Vec<RouterEvent>` collects everything that
happened: `Hovered` / `Unhovered`, `Pressed` / `Released` / `Clicked`
(per mouse button), `FocusGained` / `FocusLost`. Piece 6 will dispatch
each event to per-widget callbacks; piece 5 is purely the state machine
producing the event list.
### Tests
- 13 lib tests cover hit-test (topmost wins, anonymous skipped,
outside-root → None, padding gutter resolves to parent), hover/
unhover/swap-on-move, press → focus, press + release on the same
widget → click, drag-off cancels click, press outside clears focus,
captured-flag transitions, and cursor-unset → no hover.
- 1 integration test exercises the full hover → press → release →
click → move → press-outside-loses-focus sequence end-to-end with a
synthetic `InputState`.
All tests are pure-CPU; no GPU, no font, no window.
### What's deliberately not in piece 5
- **Keyboard focus navigation** (Tab / arrow keys to move focus) — a
small follow-up on top of the existing focus state.
- **Per-widget callbacks** — piece 6.
- **World-space hit-test** — clicking through a 3D panel needs a
ray-cast and an inverse-MVP. A follow-up that slots in by adding a
`Router::hit_test_world(ray, &UiPanel, &Transform)` helper.
## What's in piece 6 — events + data binding
Piece 6 takes the **immediate-mode** stance (same as Bevy UI and egui):
no callback storage, no `Rc<RefCell<...>>` for state, no lifetime
gymnastics — the host reads the `RouterFrame` each frame and acts
directly.
### Events: immediate-mode queries on `RouterFrame`
The piece-5 `RouterFrame` already carries the event list. Piece 6 adds
typed query methods that game code calls directly:
```rust,no_run
# fn each_frame(frame: oxide_engine::ui::RouterFrame) {
use oxide_engine::winit::event::MouseButton;
if frame.clicked_left("play") {
// start_game();
}
if frame.clicked("save", MouseButton::Right) {
// open_save_menu();
}
if frame.hovered_in("tooltip-target") {
// show_tooltip();
}
if frame.focus_gained("volume_slider") {
// ...
}
# }
```
The seven query methods — `clicked`, `clicked_left`, `pressed`,
`released`, `hovered_in`, `hovered_out`, `focus_gained`, `focus_lost`
— each take a widget id and (where applicable) a `MouseButton`, and
return `bool`. They scan the frame's event list, so the cost is linear
in the number of events emitted that frame — typically a handful.
### Data binding: `Widget::value: Option<WidgetValue>`
Every widget can carry typed state — a checkbox's bool, a slider's
float, a text input's string — independent of its `kind`. The
`WidgetValue` enum has variants `Bool(bool)` / `Int(i64)` /
`Float(f64)` / `Text(String)`, plus `From`-impls for `bool`, `i32`,
`i64`, `f32`, `f64`, `&str`, and `String`.
Per-widget access uses `Widget::value(&id)` and `Widget::set_value(&id,
v)` — both walk the subtree to find the widget by id:
```rust,no_run
use oxide_engine::ui::{Widget, WidgetValue};
# fn pull_then_push(root: &mut Widget, audio_volume: &mut f32) {
// Pull game state into the widget tree (typically at the start of frame).
root.set_value(&"volume".into(), *audio_volume);
// ... user interacts, slider widget updates its own value ...
// Push the widget tree's value back into game state (at end of frame).
if let Some(v) = root.value(&"volume".into()).and_then(|v| v.as_float()) {
*audio_volume = v as f32;
}
# }
```
For values that don't change between frames (e.g., a label's string),
no binding is needed — set it once.
### Why immediate-mode
The persistent-callback alternative (each widget owns a
`Box<dyn FnMut(...)>`) forces every callback to either:
- own its game state via `Rc<RefCell<...>>` (verbose, costs every
read), or
- borrow game state for `'static` (impossible), or
- defer to a queue (the same shape as immediate-mode, but indirected).
Immediate-mode skips all three: the widget tree is **data**, not a
network of callbacks. The host's main loop is the dispatcher; the
piece-6 queries are just convenient predicates over the event list.
### What's deliberately not in piece 6
- **Typed bindings helper** (`Bindings<T>` that registers per-field
getter/setter pairs and runs them automatically) — adds a `Box<dyn>`
abstraction over what's currently two lines of host code. Will land
alongside piece-7's settings example if the boilerplate becomes
painful.
- **Per-widget keyboard event delivery** (text input handling, hotkey
registration) — needs a focused-widget event-routing pass on top of
the piece-5 focus state. Either piece-7 or a follow-up.
### Tests
- 6 lib tests on `WidgetValue` cover accessor matching, `From`
conversions for every primitive, and RON round-trip for each variant.
- 4 lib tests on `Widget`: `find_by_id` / `find_by_id_mut` walk the
subtree, `set_value` updates a descendant by id, `with_value` builder
works, the value round-trips through `Widget`'s own RON.
- 2 lib tests on `RouterFrame`: query methods return true for matching
events, false for non-matching, across every event variant.
- 1 integration test (`settings_widget_tree_round_trips_game_state_each_frame`):
pulls game state into a settings panel, simulates user interaction +
an Apply click, pushes the widget values back into game state, and
asserts the round-trip is exact.
## What's in piece 8 — `examples/ui_hud`
`examples/src/bin/ui_hud.rs` is the second runnable Stage-8 example and
the first to **composite the UI over a 3D scene**. It reuses the
`hello_mesh` scene (spinning cube + sphere + ground plane through the
Stage-4 `ForwardPass`) and draws a HUD on top with a screen-space
`UiOverlayPass`.
### Compositing two passes on one surface
The window runner clears the surface to the configured clear color
*before* `render`. Both the forward pass and the UI overlay then use
`LoadOp::Load` for their color attachment, so each draws over whatever
is already there:
1. `pipeline.render(&mut frame)` runs the forward pass — 3D geometry
plus its own depth buffer (cleared each call).
2. `ui_pass.run(&mut frame)` runs the overlay — no depth, alpha
blending — so the HUD sits on top of the 3D image.
The host owns the `UiOverlayPass` separately from the `RenderPipeline`
(rather than `add_pass`-ing it) because the overlay needs `set_batches`
mutated every frame and the pipeline consumes pass ownership. Both
passes share the same `FrameContext`, so the example builds the 3D
objects and the painted HUD, then calls the two `run`s back to back.
### Corner anchoring
Each HUD element is an anchor child of a full-screen anchor root. A
corner-pinned, fixed-size widget is expressed as a corner `Anchor`
constant plus offsets that define its box — e.g. a top-left chip is
`Anchor::TOP_LEFT.with_offsets((M, M), (M + W, M + H))`, and a centred
crosshair is `Anchor::between((0.5, 0.5), (0.5, 0.5)).with_offsets(...)`.
The crosshair's two bars are themselves anchor children spanning one
axis and pinned thin on the other.
### Demonstrating the atlas cache
The HP and Ammo values animate every frame (HP oscillates down then up;
Ammo counts down as if firing, reloading at 0). The digits change
constantly, but the glyph atlas only ever rasterizes each character
**once** — after the digits `0``9` and the static label text have been
seen, the atlas stops growing and every later frame is a pure cache hit
(no rasterize, no GPU re-upload). The example logs each atlas growth and
the moment it reaches steady state, via two accessors added to the pass:
```rust
pass.atlas_glyph_count(); // distinct glyphs cached so far
pass.atlas_dirty(); // grew-this-run flag (false in steady state)
```
The `atlas_caches_glyphs_and_reaches_steady_state` GPU test in
`render::ui_pass` proves this property automatically: it draws the ten
digits one per frame (asserting the count grows by one each time), then
re-draws a cached digit and asserts the count holds and the dirty flag
stays clear.
Run it: `cargo run -p oxide-examples --bin ui_hud` (Esc quits).
## What's coming in the rest of Stage 8
- **Piece 9 — Editor UI canvas.** A new editor panel for visually
authoring `Widget` / `UiPanel` documents: a drag-from widget palette,
a canvas showing the document at target size with drag-resize handles,
a property inspector for `LayoutStyle` / `VisualStyle` / `text` /
`value`, RON save/load round-tripping the same format the runtime
loads, and a live preview rendered through the actual `UiOverlayPass`
(not egui). Likely splits into 9a (canvas + palette + inspector) and
9b (live preview + drag/resize handles).
The piece-1 data structures already accommodate the editor canvas: a
document is just a `Widget` tree, the inspector edits the same reflected
style structs the runtime uses, and the preview reuses the exact paint +
overlay pipeline the game ships with.
+144
View File
@@ -0,0 +1,144 @@
# Windowing & the Application Loop
Stage 2 reference for `oxide_engine::window` — opening a window, running the
event loop, and receiving raw input. For what happens *inside* a frame (GPU
setup, clearing, resize handling) see [render-context.md](render-context.md).
## Overview
The window module wraps [`winit`](https://docs.rs/winit) so applications never
talk to the event loop directly. You implement the `WindowApp` trait, hand it
to `run()` together with a `WindowConfig`, and the engine:
1. creates the window and the GPU [`RenderContext`](render-context.md),
2. calls `WindowApp::init` once,
3. then loops: forwards every raw window event to `WindowApp::event`, calls
`WindowApp::update` once per frame, and clears + presents the surface.
The loop runs in `Poll` mode (continuous rendering, as a game expects), not
event-driven `Wait` mode (as a desktop utility would use).
> **Stage 6 rename.** This trait was originally `App`. Stage 6 renamed it to
> `WindowApp` so the engine's [`oxide_engine::app::App`](modules.md) container
> (scene, assets, scheduled systems) could live in the prelude unambiguously.
> The two cover different roles: this trait is the per-frame window/event
> handler; the container is engine state your handler typically wraps around.
## Minimal application
```rust
use oxide_engine::prelude::*;
use oxide_engine::window::event::{Key, NamedKey, ElementState, WindowEvent};
#[derive(Default)]
struct MyApp;
impl WindowApp for MyApp {
fn init(&mut self, ctx: &mut AppCtx<'_>) {
ctx.set_clear_color(Color::rgb(0.39, 0.58, 0.93));
}
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
if let WindowEvent::KeyboardInput { event: key, .. } = event {
if key.state == ElementState::Pressed
&& key.logical_key == Key::Named(NamedKey::Escape)
{
ctx.request_exit();
}
}
}
fn update(&mut self, ctx: &mut AppCtx<'_>) {
let _seconds_since_last_frame = ctx.dt;
}
}
fn main() -> anyhow::Result<()> {
run(WindowConfig::default(), MyApp)
}
```
`run()` blocks the calling thread until the app exits — an OS requirement (the
event loop must own the main thread), not an engine choice.
## `WindowConfig`
Initial window settings. All fields are plain data:
| Field | Default | Meaning |
|-------|---------|---------|
| `title` | `"Oxide"` | Window title |
| `width`, `height` | 1280 × 720 | Initial inner size, logical pixels |
| `resizable` | `true` | Whether the user can resize |
| `clear_color` | `Color::BLACK` | Initial per-frame clear color |
## The `WindowApp` trait
Three callbacks, all optional (empty default bodies):
- **`init(ctx)`** — once, after the window and GPU exist, before the first
frame. Set the title, clear color, load resources.
- **`event(ctx, event)`** — for *every* raw `WindowEvent`, including ones the
engine also reacts to (close request, resize), so apps can observe
everything. Stage 2 exposes events untranslated; the
[Stage-7 input system](input.md) layers per-key edge detection and
remappable named actions on top, surfaced through `ctx.input()`.
- **`update(ctx)`** — once per frame, before the frame is cleared and
presented. `ctx.dt` is the seconds elapsed since the previous frame (`0.0`
on the first).
Per frame the order is: pending `event` calls → `update` → render.
## `AppCtx`
Every callback receives `&mut AppCtx`, the engine state an app may touch:
| Member | Purpose |
|--------|---------|
| `dt` | Frame delta time in seconds (field) |
| `set_clear_color(color)` / `clear_color()` | Per-frame clear color; changes apply on the next frame |
| `size()` | Current surface size in physical pixels |
| `set_title(title)` | Change the window title |
| `request_exit()` | Leave the event loop after the current callback |
| `render()` | Direct access to the [`RenderContext`](render-context.md) |
| `input()` | The per-frame [`InputState`](input.md) snapshot |
## Raw event types
`oxide_engine::window::event` re-exports the `winit` event vocabulary
(`WindowEvent`, `KeyEvent`, `MouseButton`, `ElementState`, `KeyCode`,
`PhysicalKey`, `Key`, `NamedKey`, `ModifiersState`, …) so applications don't
need their own `winit` dependency. The whole crates are also available as
`oxide_engine::winit` and `oxide_engine::wgpu` for anything not curated.
Two keyboard representations matter:
- `KeyEvent::physical_key` (`PhysicalKey::Code(KeyCode::KeyW)`) — the physical
key position, layout-independent. Use for game-style controls.
- `KeyEvent::logical_key` (`Key::Named(NamedKey::Escape)` or
`Key::Character(…)`) — what the key means under the user's layout. Use for
shortcuts and text.
## Engine-handled events
The runner reacts to these before forwarding them:
| Event | Engine behavior |
|-------|-----------------|
| `CloseRequested` | Exits the loop (apps can't veto it in Stage 2) |
| `Resized` | Reconfigures the surface (see [render-context.md](render-context.md)) |
| `RedrawRequested` | Computes `dt`, calls `update`, renders the frame |
Errors during window/GPU creation or rendering are returned from `run()`;
winit callbacks can't propagate `Result`, so the runner stashes the first
error and exits the loop.
## Trying it
```sh
cargo run -p oxide-examples --bin hello_window
```
Keys `1``5` switch clear-color presets, `Space` cycles, `Esc` quits; average
FPS is logged once per second. The editor (`cargo run -p oxide-editor`) uses
the same infrastructure and quits with `Ctrl+Q`.
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "oxide-editor"
description = "Oxide Engine — in-engine editor"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "oxide-editor"
path = "src/main.rs"
[dependencies]
oxide-engine = { path = "../engine" }
oxide-physics = { path = "../physics" }
oxide-script = { path = "../script" }
log.workspace = true
env_logger.workspace = true
anyhow.workspace = true
egui.workspace = true
egui-wgpu.workspace = true
egui-winit.workspace = true
egui_dock.workspace = true
# Editor preferences file I/O reads/writes the same RON shape `Settings`
# exports; the engine already pulls `ron` in, the editor now does too.
ron.workspace = true
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
# opens a real pseudo-terminal (cross-platform: Linux now, Windows later);
# `vt100` parses the program's byte stream into a screen grid the panel renders.
portable-pty = "0.9"
vt100 = "0.16"
+127
View File
@@ -0,0 +1,127 @@
//! Bundled editor assets — locating the shared `assets/` tree and seeding a
//! new project's default content (currently the default UI font).
//!
//! The editor ships a small set of shared assets (icons, the default UI font, …)
//! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have
//! to find that tree whether the editor is *installed* or run from a *dev*
//! checkout, so [`bundled_assets_dir`] resolves it in priority order:
//!
//! 1. the `OXIDE_ASSETS_DIR` environment variable, if set (explicit override);
//! 2. `<exe>/../share/oxide/assets` — the install layout (`bin/` next to
//! `share/`);
//! 3. `<crate>/../assets` — the repo's top-level `assets/` for `cargo run`.
use std::path::{Path, PathBuf};
/// The default UI font's path, relative to the bundled `assets/` directory and
/// to a project's `assets/` directory (they share the typed-folder layout).
///
/// Inter (SIL Open Font License) — the variable font's default instance is the
/// Regular weight. The license travels next to it as `fonts/OFL.txt`.
pub const DEFAULT_UI_FONT_REL: &str = "fonts/InterVariable.ttf";
/// The default UI font's license file, copied alongside the font so a project
/// (and any game exported from it) carries the attribution the OFL requires.
pub const DEFAULT_UI_FONT_LICENSE_REL: &str = "fonts/OFL.txt";
/// Locates the editor's bundled `assets/` directory, or `None` if no candidate
/// exists (e.g. a stripped install missing its share tree).
pub fn bundled_assets_dir() -> Option<PathBuf> {
// 1. Explicit override.
if let Some(dir) = std::env::var_os("OXIDE_ASSETS_DIR") {
let dir = PathBuf::from(dir);
if dir.is_dir() {
return Some(dir);
}
}
// 2. Installed layout: <prefix>/bin/oxide-editor + <prefix>/share/oxide/assets.
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
if let Some(prefix) = bin_dir.parent() {
let installed = prefix.join("share/oxide/assets");
if installed.is_dir() {
return Some(installed);
}
}
}
}
// 3. Dev checkout: the repo's top-level `assets/` sits one level above this
// crate (`editor/`).
let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../assets");
dev.is_dir().then_some(dev)
}
/// The absolute path of the bundled default UI font, if the assets tree was
/// found and the font is present.
pub fn default_ui_font_source() -> Option<PathBuf> {
let path = bundled_assets_dir()?.join(DEFAULT_UI_FONT_REL);
path.is_file().then_some(path)
}
/// Copies the bundled default UI font (and its license) into `project_assets_dir`
/// under the same relative path, unless a file is already there. Returns whether
/// the font was newly copied. A missing bundle is a no-op (returns `false`).
///
/// Called when a project is created so the asset browser has a usable font to
/// pick from immediately, referenced by the project-relative path the
/// [`AssetDatabase`](oxide_engine::asset::AssetDatabase) records.
pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
let Some(src) = default_ui_font_source() else {
return Ok(false);
};
let dst = project_assets_dir.join(DEFAULT_UI_FONT_REL);
if dst.exists() {
return Ok(false);
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dst)?;
// Best-effort: carry the license next to the font (don't fail the seed if
// only the license is missing from the bundle).
if let Some(bundle) = bundled_assets_dir() {
let lic_src = bundle.join(DEFAULT_UI_FONT_LICENSE_REL);
if lic_src.is_file() {
let _ = std::fs::copy(
lic_src,
project_assets_dir.join(DEFAULT_UI_FONT_LICENSE_REL),
);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_resolves_in_dev_checkout() {
// Running tests from the workspace, the dev-checkout fallback (3) finds
// the repo's top-level assets/ with the bundled font.
let dir = bundled_assets_dir().expect("bundled assets dir should resolve in dev");
assert!(
dir.join(DEFAULT_UI_FONT_REL).is_file(),
"default font present"
);
assert!(default_ui_font_source().is_some());
}
#[test]
fn seed_copies_font_once() {
let mut tmp = std::env::temp_dir();
tmp.push(format!("oxide_seedfont_{}", std::process::id()));
let assets = tmp.join("assets");
std::fs::create_dir_all(&assets).unwrap();
assert!(seed_default_font(&assets).unwrap(), "first seed copies");
assert!(assets.join(DEFAULT_UI_FONT_REL).is_file());
// Idempotent: a second seed finds the file already present.
assert!(
!seed_default_font(&assets).unwrap(),
"second seed is a no-op"
);
std::fs::remove_dir_all(tmp).ok();
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Default editor input bindings + the action-name constants the bindings
//! UI and any debug overlay address.
//!
//! Living in the editor library (not the binary) so the Stage-7
//! [`InputBindings`](crate::shell::Shell) preferences page and any future
//! editor module can re-register or remap the same actions without
//! depending on the binary's private module.
use oxide_engine::input::{ActionMap, AxisBinding, Binding};
use oxide_engine::winit::keyboard::KeyCode;
/// The settings-section name under which the editor's
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) are persisted.
///
/// The shell registers this section automatically in
/// [`EditorState::new`](crate::state::EditorState::new); UI code that wants
/// to refresh the section after a binding change addresses it by this name.
pub const SETTINGS_SECTION: &str = "input.bindings";
/// Stable action names addressed throughout the editor — the bindings
/// preferences page, the camera input poll in the runner, and any future
/// debug overlay all reference these strings.
pub mod action {
/// Button: toggle between orbit and flythrough viewport cameras.
pub const TOGGLE_FLYTHROUGH: &str = "editor.camera.toggle_flythrough";
/// Button (held): accelerate flythrough translation while engaged.
pub const SPRINT: &str = "editor.camera.sprint";
/// 1D axis: strafe right (+) / strafe left () in flythrough mode.
pub const MOVE_RIGHT: &str = "editor.camera.move_right";
/// 1D axis: forward (+) / back () in flythrough mode.
pub const MOVE_FORWARD: &str = "editor.camera.move_forward";
/// 1D axis: ascend (+) / descend () in flythrough mode.
pub const MOVE_UP: &str = "editor.camera.move_up";
/// Button: switch the transform gizmo to Translate mode (orbit camera only).
pub const GIZMO_TRANSLATE: &str = "editor.gizmo.translate";
/// Button: switch the transform gizmo to Rotate mode (orbit camera only).
pub const GIZMO_ROTATE: &str = "editor.gizmo.rotate";
/// Button: switch the transform gizmo to Scale mode (orbit camera only).
pub const GIZMO_SCALE: &str = "editor.gizmo.scale";
}
/// Registers the editor's default action set on `actions`. Defaults follow
/// the DCC-tools convention (WASD + QE, Shift sprint, F toggles the
/// flythrough camera) so users coming from Blender / Maya / Unity feel at
/// home.
///
/// Idempotent on the action names — re-registering preserves any user-
/// remapped current bindings while refreshing the defaults that the
/// "Restore defaults" button reverts to.
pub fn register_defaults(actions: &mut ActionMap) {
actions
.register(action::TOGGLE_FLYTHROUGH, [Binding::Key(KeyCode::KeyF)])
.register(
action::SPRINT,
[
Binding::Key(KeyCode::ShiftLeft),
Binding::Key(KeyCode::ShiftRight),
],
)
.register_axis(
action::MOVE_RIGHT,
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)]),
)
.register_axis(
action::MOVE_FORWARD,
AxisBinding::new([Binding::Key(KeyCode::KeyW)], [Binding::Key(KeyCode::KeyS)]),
)
.register_axis(
action::MOVE_UP,
AxisBinding::new([Binding::Key(KeyCode::KeyE)], [Binding::Key(KeyCode::KeyQ)]),
)
// Gizmo tool hotkeys (W/E/R). These share physical keys with
// flythrough movement, so the host gates them on the camera being
// in orbit mode — in flythrough W/E move the camera, in orbit
// they switch the gizmo tool.
.register(action::GIZMO_TRANSLATE, [Binding::Key(KeyCode::KeyW)])
.register(action::GIZMO_ROTATE, [Binding::Key(KeyCode::KeyE)])
.register(action::GIZMO_SCALE, [Binding::Key(KeyCode::KeyR)]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_register_every_advertised_action() {
let mut actions = ActionMap::new();
register_defaults(&mut actions);
assert!(actions.has(action::TOGGLE_FLYTHROUGH));
assert!(actions.has(action::SPRINT));
assert!(actions.has_axis(action::MOVE_RIGHT));
assert!(actions.has_axis(action::MOVE_FORWARD));
assert!(actions.has_axis(action::MOVE_UP));
}
#[test]
fn defaults_are_idempotent_and_preserve_remaps() {
let mut actions = ActionMap::new();
register_defaults(&mut actions);
// User remaps Toggle to Tab.
actions.set_bindings(action::TOGGLE_FLYTHROUGH, vec![Binding::Key(KeyCode::Tab)]);
// Re-running register_defaults must not stomp the user's remap.
register_defaults(&mut actions);
assert_eq!(
actions.bindings(action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KeyCode::Tab)]
);
// But the defaults — what "Restore defaults" reverts to — are still F.
assert_eq!(
actions.defaults(action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KeyCode::KeyF)]
);
}
}
+335
View File
@@ -0,0 +1,335 @@
//! The editor's central undo/redo command stack.
//!
//! Every editor mutation that should be undoable — a transform edit, a rename, a
//! spawn/despawn, and later sculpt/paint/scatter brush strokes — is expressed as
//! a [`Command`] and pushed onto a [`CommandStack`]. Routing *all* edits through
//! one stack is what makes undo/redo consistent across the whole editor, and it
//! is why the Stage-7 gizmos and every later tool get undo "for free".
//!
//! The stack is generic over the context `C` a command mutates (in the editor
//! that is the scene + editor state), which keeps it decoupled and unit-testable
//! against a trivial context.
use std::any::Any;
/// A reversible editor action over a context `C`.
///
/// A command must be able to [`apply`](Self::apply) its effect and exactly
/// [`undo`](Self::undo) it. Commands are stored boxed on the [`CommandStack`].
pub trait Command<C>: 'static {
/// Performs the action, mutating `ctx`.
fn apply(&mut self, ctx: &mut C);
/// Reverses the action, restoring `ctx` to its pre-[`apply`](Self::apply) state.
fn undo(&mut self, ctx: &mut C);
/// A short human-readable label (shown in the Edit menu / history).
fn label(&self) -> String;
/// Upcast for [`merge`](Self::merge) to downcast a following command.
/// Implement as `self`.
fn as_any_mut(&mut self) -> &mut dyn Any;
/// Tries to fold the immediately-following command `next` into this one so
/// they share a single undo entry (e.g. every frame of a gizmo drag becomes
/// one undoable move). Return `true` if absorbed; the default never merges.
///
/// When merging, update `self` so that undoing it reverses *both* effects.
fn merge(&mut self, next: &mut dyn Command<C>) -> bool {
let _ = next;
false
}
}
/// A composite command: several commands grouped into one undo entry.
///
/// Applied front-to-back and undone back-to-front, so a multi-step operation
/// (e.g. "duplicate and offset") is a single, atomic undo.
pub struct Group<C> {
label: String,
commands: Vec<Box<dyn Command<C>>>,
}
impl<C: 'static> Group<C> {
/// A new, empty group with the given label.
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
commands: Vec::new(),
}
}
/// Adds a command to the group (not yet applied).
pub fn push(&mut self, command: impl Command<C> + 'static) {
self.commands.push(Box::new(command));
}
/// Whether the group has no commands.
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
impl<C: 'static> Command<C> for Group<C> {
fn apply(&mut self, ctx: &mut C) {
for command in &mut self.commands {
command.apply(ctx);
}
}
fn undo(&mut self, ctx: &mut C) {
for command in self.commands.iter_mut().rev() {
command.undo(ctx);
}
}
fn label(&self) -> String {
self.label.clone()
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
/// A bounded undo/redo stack of [`Command`]s over a context `C`.
///
/// Pushing a command applies it and clears the redo history. Capacity caps how
/// many undo entries are retained (oldest dropped first) so the history cannot
/// grow without bound.
pub struct CommandStack<C> {
undo: Vec<Box<dyn Command<C>>>,
redo: Vec<Box<dyn Command<C>>>,
capacity: usize,
}
impl<C: 'static> CommandStack<C> {
/// The default maximum number of retained undo entries.
pub const DEFAULT_CAPACITY: usize = 256;
/// A stack with the [default capacity](Self::DEFAULT_CAPACITY).
pub fn new() -> Self {
Self::with_capacity(Self::DEFAULT_CAPACITY)
}
/// A stack retaining at most `capacity` undo entries (minimum 1).
pub fn with_capacity(capacity: usize) -> Self {
Self {
undo: Vec::new(),
redo: Vec::new(),
capacity: capacity.max(1),
}
}
/// Applies `command` and records it, clearing the redo history.
///
/// If the previous top entry [`merge`](Command::merge)s this command, the two
/// share one undo entry instead of pushing a new one.
pub fn push(&mut self, command: impl Command<C> + 'static, ctx: &mut C) {
self.push_boxed(Box::new(command), ctx);
}
/// Applies and records an already-boxed command (e.g. a [`Group`]).
pub fn push_boxed(&mut self, mut command: Box<dyn Command<C>>, ctx: &mut C) {
command.apply(ctx);
self.redo.clear();
if let Some(top) = self.undo.last_mut() {
if top.merge(command.as_mut()) {
return;
}
}
self.undo.push(command);
while self.undo.len() > self.capacity {
self.undo.remove(0);
}
}
/// Undoes the most recent command, moving it to the redo history. Returns its
/// label, or `None` if there was nothing to undo.
pub fn undo(&mut self, ctx: &mut C) -> Option<String> {
let mut command = self.undo.pop()?;
command.undo(ctx);
let label = command.label();
self.redo.push(command);
Some(label)
}
/// Redoes the most recently undone command. Returns its label, or `None`.
pub fn redo(&mut self, ctx: &mut C) -> Option<String> {
let mut command = self.redo.pop()?;
command.apply(ctx);
let label = command.label();
self.undo.push(command);
Some(label)
}
/// Whether there is anything to undo.
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
/// Whether there is anything to redo.
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
/// The label of the next undo, if any (for the Edit menu).
pub fn undo_label(&self) -> Option<String> {
self.undo.last().map(|c| c.label())
}
/// The label of the next redo, if any.
pub fn redo_label(&self) -> Option<String> {
self.redo.last().map(|c| c.label())
}
/// The number of retained undo entries.
pub fn undo_depth(&self) -> usize {
self.undo.len()
}
/// Clears all history (e.g. on project close).
pub fn clear(&mut self) {
self.undo.clear();
self.redo.clear();
}
}
impl<C: 'static> Default for CommandStack<C> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A trivial context: a single integer the test commands mutate.
type Ctx = i32;
/// Adds `amount` to the context; undo subtracts it. Consecutive `Add`s merge
/// into one undo entry (modeling a continuous drag).
struct Add {
amount: i32,
mergeable: bool,
}
impl Add {
fn new(amount: i32) -> Self {
Self {
amount,
mergeable: true,
}
}
fn standalone(amount: i32) -> Self {
Self {
amount,
mergeable: false,
}
}
}
impl Command<Ctx> for Add {
fn apply(&mut self, ctx: &mut Ctx) {
*ctx += self.amount;
}
fn undo(&mut self, ctx: &mut Ctx) {
*ctx -= self.amount;
}
fn label(&self) -> String {
format!("Add {}", self.amount)
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn merge(&mut self, next: &mut dyn Command<Ctx>) -> bool {
if !self.mergeable {
return false;
}
if let Some(other) = next.as_any_mut().downcast_mut::<Add>() {
if other.mergeable {
// Fold next's effect into this entry: undoing reverses both.
self.amount += other.amount;
return true;
}
}
false
}
}
#[test]
fn apply_undo_redo_round_trip() {
let mut ctx: Ctx = 0;
let mut stack = CommandStack::new();
stack.push(Add::standalone(5), &mut ctx);
stack.push(Add::standalone(3), &mut ctx);
assert_eq!(ctx, 8);
assert_eq!(stack.undo_depth(), 2);
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 3"));
assert_eq!(ctx, 5);
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Add 5"));
assert_eq!(ctx, 0);
assert!(!stack.can_undo());
assert_eq!(stack.redo(&mut ctx).as_deref(), Some("Add 5"));
assert_eq!(ctx, 5);
assert!(stack.can_redo());
}
#[test]
fn pushing_clears_redo() {
let mut ctx: Ctx = 0;
let mut stack = CommandStack::new();
stack.push(Add::standalone(1), &mut ctx);
stack.undo(&mut ctx);
assert!(stack.can_redo());
stack.push(Add::standalone(10), &mut ctx); // new edit invalidates redo
assert!(!stack.can_redo());
assert_eq!(ctx, 10);
}
#[test]
fn consecutive_mergeable_commands_share_one_entry() {
let mut ctx: Ctx = 0;
let mut stack = CommandStack::new();
// Simulate a drag: many small mergeable adds.
for _ in 0..5 {
stack.push(Add::new(2), &mut ctx);
}
assert_eq!(ctx, 10);
assert_eq!(stack.undo_depth(), 1, "drag should be one undo entry");
// A single undo reverses the whole drag.
stack.undo(&mut ctx);
assert_eq!(ctx, 0);
}
#[test]
fn group_is_atomic() {
let mut ctx: Ctx = 0;
let mut stack = CommandStack::new();
let mut group = Group::new("Duplicate+Offset");
group.push(Add::standalone(4));
group.push(Add::standalone(6));
stack.push_boxed(Box::new(group), &mut ctx);
assert_eq!(ctx, 10);
assert_eq!(stack.undo_depth(), 1);
assert_eq!(stack.undo(&mut ctx).as_deref(), Some("Duplicate+Offset"));
assert_eq!(ctx, 0, "group undoes as one atomic step");
}
#[test]
fn capacity_drops_oldest_entries() {
let mut ctx: Ctx = 0;
let mut stack = CommandStack::with_capacity(3);
for i in 1..=5 {
stack.push(Add::standalone(i), &mut ctx);
}
// Only the last 3 entries are retained for undo.
assert_eq!(stack.undo_depth(), 3);
// Undoing all retained entries removes 3+4+5 = 12 from the final 15.
while stack.undo(&mut ctx).is_some() {}
assert_eq!(ctx, 1 + 2); // the dropped 1 and 2 can't be undone
}
}
+415
View File
@@ -0,0 +1,415 @@
//! Concrete editor commands that mutate the [`EditorState`](crate::state::EditorState).
//!
//! Routed through the [`CommandStack`](crate::command::CommandStack) so every
//! one is undoable through the Edit menu, `Ctrl+Z`/`Ctrl+Y`, and the same path
//! that future tools (transform gizmos, sculpt, paint) will use.
//!
//! Piece 6 ships the **first** commands so the undo plumbing is exercised
//! end-to-end:
//!
//! - [`SetTransformCmd`] — change an entity's local [`Transform`]. Consecutive
//! edits to the same entity coalesce via [`Command::merge`] so a slider drag
//! or a (future) gizmo drag becomes one undo entry.
//! - [`RenameCmd`] — rename an entity.
//!
//! Spawn/despawn aren't wired yet: round-tripping a despawn would need stable
//! entity ids across re-spawn (the scene reuses ids), which is a Stage-7
//! design step. The hierarchy panel still offers Add/Delete; they bypass the
//! stack today and are clearly labeled as "not undoable" in the shell.
use std::any::Any;
use oxide_engine::prelude::*;
use crate::command::Command;
use crate::state::EditorState;
/// Replaces the open UI document's panel wholesale (widget tree + sizes).
///
/// The UI canvas snapshots the panel before an edit and again after, so any
/// structural change (add / remove / move a widget) or property change goes
/// through one undoable command without per-operation bookkeeping. A panel is a
/// small data tree, so cloning it for the snapshots is cheap.
pub struct SetUiPanelCmd {
/// The panel before the edit.
pub before: UiPanel,
/// The panel after the edit.
pub after: UiPanel,
/// Human-readable description for the Edit menu.
pub label: String,
}
impl Command<EditorState> for SetUiPanelCmd {
fn apply(&mut self, state: &mut EditorState) {
if let Some(doc) = &mut state.ui_doc {
doc.panel = self.after.clone();
doc.dirty = true;
}
}
fn undo(&mut self, state: &mut EditorState) {
if let Some(doc) = &mut state.ui_doc {
doc.panel = self.before.clone();
doc.dirty = true;
}
}
fn label(&self) -> String {
self.label.clone()
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
/// Replaces an entity's local [`Transform`]. Coalesces consecutive edits to
/// the same entity so an interactive drag is one undo entry.
pub struct SetTransformCmd {
pub entity: Entity,
/// The transform before the first apply — preserved through merges so
/// undo reverses the whole drag at once.
pub before: Transform,
/// The transform after the most recent apply.
pub after: Transform,
}
impl SetTransformCmd {
/// Builds the command, snapshotting the entity's current transform as the
/// pre-edit state. Returns `None` if the entity has no transform (e.g. it
/// was just despawned).
pub fn new(state: &EditorState, entity: Entity, after: Transform) -> Option<Self> {
let before = state.scene.local_transform(entity)?;
Some(Self {
entity,
before,
after,
})
}
}
impl Command<EditorState> for SetTransformCmd {
fn apply(&mut self, state: &mut EditorState) {
state.scene.set_local_transform(self.entity, self.after);
}
fn undo(&mut self, state: &mut EditorState) {
state.scene.set_local_transform(self.entity, self.before);
}
fn label(&self) -> String {
"Edit Transform".to_owned()
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
let Some(next) = next.as_any_mut().downcast_mut::<SetTransformCmd>() else {
return false;
};
if next.entity != self.entity {
return false;
}
// Absorb `next` by extending our `after` while preserving `before`,
// so a long drag remains a single undo step.
self.after = next.after;
true
}
}
/// Sets a single **reflected field** of a component on an entity, addressed by
/// type name + field name and carried as RON.
///
/// This is the generic counterpart to [`SetTransformCmd`]: the
/// reflection-driven inspector emits one of these for *any* registered
/// component's field, so a new component type becomes undoably editable with no
/// new command type. Consecutive edits to the same `(entity, type, field)`
/// coalesce via [`Command::merge`], so dragging a value slider is one undo
/// entry.
pub struct SetFieldCmd {
pub entity: Entity,
/// The registered type name (e.g. `"Transform"`).
pub type_name: &'static str,
/// The reflected field name (e.g. `"translation"`).
pub field: &'static str,
/// The field's RON before the first apply — preserved through merges.
pub before: String,
/// The field's RON after the most recent apply.
pub after: String,
}
impl SetFieldCmd {
/// Builds the command, snapshotting the field's current RON as the
/// pre-edit state. Returns `None` if the field can't be read (unknown
/// type/field, or the entity lacks the component).
pub fn new(
state: &EditorState,
entity: Entity,
type_name: &'static str,
field: &'static str,
after: String,
) -> Option<Self> {
let before = state
.registry
.get_field(state.scene.world(), entity, type_name, field)
.ok()?;
Some(Self {
entity,
type_name,
field,
before,
after,
})
}
}
impl Command<EditorState> for SetFieldCmd {
fn apply(&mut self, state: &mut EditorState) {
// Disjoint borrows of EditorState: &registry (receiver) + &mut scene
// (the world). A write only fails if the entity/component vanished
// between snapshot and apply, in which case there's nothing to do.
let _ = state.registry.set_field(
state.scene.world_mut(),
self.entity,
self.type_name,
self.field,
&self.after,
);
}
fn undo(&mut self, state: &mut EditorState) {
let _ = state.registry.set_field(
state.scene.world_mut(),
self.entity,
self.type_name,
self.field,
&self.before,
);
}
fn label(&self) -> String {
format!("Edit {}.{}", self.type_name, self.field)
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn merge(&mut self, next: &mut dyn Command<EditorState>) -> bool {
let Some(next) = next.as_any_mut().downcast_mut::<SetFieldCmd>() else {
return false;
};
// Only coalesce edits to the *same* field of the same component on the
// same entity; preserve `before` so undo reverses the whole drag.
if next.entity != self.entity
|| next.type_name != self.type_name
|| next.field != self.field
{
return false;
}
self.after = std::mem::take(&mut next.after);
true
}
}
/// Renames an entity.
pub struct RenameCmd {
pub entity: Entity,
pub before: String,
pub after: String,
}
impl RenameCmd {
/// Snapshots the entity's current name as the pre-edit state.
pub fn new(state: &EditorState, entity: Entity, after: String) -> Self {
let before = state.scene.name(entity).unwrap_or_default();
Self {
entity,
before,
after,
}
}
}
impl Command<EditorState> for RenameCmd {
fn apply(&mut self, state: &mut EditorState) {
state.scene.set_name(self.entity, self.after.clone());
}
fn undo(&mut self, state: &mut EditorState) {
state.scene.set_name(self.entity, self.before.clone());
}
fn label(&self) -> String {
format!("Rename to '{}'", self.after)
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::CommandStack;
fn state_with_entity() -> (EditorState, Entity) {
let mut state = EditorState::new();
let e = state
.scene
.spawn("alpha", Transform::from_translation(Vec3::ZERO));
(state, e)
}
#[test]
fn transform_undo_redo_round_trips() {
let (mut state, e) = state_with_entity();
let target = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
let cmd = SetTransformCmd::new(&state, e, target).expect("transform present");
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
stack.push(cmd, &mut state);
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
target.translation
);
assert!(stack.undo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::ZERO
);
assert!(stack.redo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
target.translation
);
}
#[test]
fn consecutive_transform_edits_coalesce_into_one_undo() {
// Mirrors the "interactive drag" case: dozens of per-frame edits, one
// undo step that returns to the pre-drag state.
let (mut state, e) = state_with_entity();
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
for step in 1..=5 {
let target = Transform::from_translation(Vec3::splat(step as f32));
let cmd = SetTransformCmd::new(&state, e, target).unwrap();
stack.push(cmd, &mut state);
}
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::splat(5.0)
);
// A single undo wipes the whole drag — that's the merge contract.
assert!(stack.undo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::ZERO
);
}
#[test]
fn set_field_undo_redo_round_trips() {
let (mut state, e) = state_with_entity();
let cmd = SetFieldCmd::new(
&state,
e,
"Transform",
"translation",
"(1.0,2.0,3.0)".into(),
)
.expect("transform field readable");
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
stack.push(cmd, &mut state);
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::new(1.0, 2.0, 3.0)
);
assert!(stack.undo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::ZERO
);
assert!(stack.redo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::new(1.0, 2.0, 3.0)
);
}
#[test]
fn consecutive_field_edits_to_same_field_coalesce() {
// A value-slider drag: many per-frame edits, one undo back to start.
let (mut state, e) = state_with_entity();
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
for step in 1..=5 {
let ron = format!("({0}.0,{0}.0,{0}.0)", step);
let cmd = SetFieldCmd::new(&state, e, "Transform", "translation", ron).unwrap();
stack.push(cmd, &mut state);
}
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::splat(5.0)
);
assert!(stack.undo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::ZERO
);
}
#[test]
fn set_field_edits_to_different_fields_do_not_coalesce() {
// Editing translation then scale must be two undo steps, not one.
let (mut state, e) = state_with_entity();
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
stack.push(
SetFieldCmd::new(
&state,
e,
"Transform",
"translation",
"(1.0,0.0,0.0)".into(),
)
.unwrap(),
&mut state,
);
stack.push(
SetFieldCmd::new(&state, e, "Transform", "scale", "(2.0,2.0,2.0)".into()).unwrap(),
&mut state,
);
// Undo reverses scale only.
assert!(stack.undo(&mut state).is_some());
let t = state.scene.local_transform(e).unwrap();
assert_eq!(t.scale, Vec3::ONE);
assert_eq!(t.translation, Vec3::new(1.0, 0.0, 0.0));
// A second undo reverses translation.
assert!(stack.undo(&mut state).is_some());
assert_eq!(
state.scene.local_transform(e).unwrap().translation,
Vec3::ZERO
);
}
#[test]
fn rename_undo_restores_previous_name() {
let (mut state, e) = state_with_entity();
let cmd = RenameCmd::new(&state, e, "beta".into());
let mut stack: CommandStack<EditorState> = CommandStack::with_capacity(32);
stack.push(cmd, &mut state);
assert_eq!(state.scene.name(e).as_deref(), Some("beta"));
assert!(stack.undo(&mut state).is_some());
assert_eq!(state.scene.name(e).as_deref(), Some("alpha"));
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Editor console: captures `log` records into a ring buffer the Console panel
//! renders.
//!
//! The engine and modules already speak through the `log` crate — in particular
//! the scripting layer routes script `print`/`debug` and "script paused: …"
//! errors to `target: "oxide_script"` (see `oxide-script`). This module installs
//! a logger that mirrors every record into an in-memory ring buffer *and* still
//! forwards it to `env_logger` for the terminal, so the editor's Console panel
//! can show script output and errors without the engine knowing about the editor.
//!
//! The buffer is a process global (the `log` facade allows only one logger, set
//! once at startup), reached by the panel through [`log_buffer`] — so wiring it
//! in touches neither `Shell::new` nor its many test call sites.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use log::{Level, Log, Metadata, Record};
/// How many recent log lines the console keeps. Older lines are dropped.
const CAPACITY: usize = 2000;
/// One captured log record, flattened to what the panel renders.
#[derive(Debug, Clone)]
pub struct LogLine {
/// Severity, used to colour the line.
pub level: Level,
/// The record's target (e.g. `oxide_script`), shown dimmed before the text.
pub target: String,
/// The formatted message.
pub message: String,
}
/// A bounded ring buffer of the most recent [`LogLine`]s.
#[derive(Default)]
pub struct LogBuffer {
lines: VecDeque<LogLine>,
}
impl LogBuffer {
/// Appends a line, evicting the oldest if at capacity.
fn push(&mut self, line: LogLine) {
if self.lines.len() == CAPACITY {
self.lines.pop_front();
}
self.lines.push_back(line);
}
/// Iterates the buffered lines, oldest first.
pub fn iter(&self) -> impl Iterator<Item = &LogLine> {
self.lines.iter()
}
/// The number of buffered lines.
pub fn len(&self) -> usize {
self.lines.len()
}
/// Whether the buffer is empty.
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
/// Drops all buffered lines (the panel's Clear button).
pub fn clear(&mut self) {
self.lines.clear();
}
}
/// The process-wide capture buffer, set by [`init`].
static LOG_BUFFER: OnceLock<Arc<Mutex<LogBuffer>>> = OnceLock::new();
/// The shared capture buffer, if logging has been initialised.
pub fn log_buffer() -> Option<&'static Arc<Mutex<LogBuffer>>> {
LOG_BUFFER.get()
}
/// Appends a line to the console from outside the `log` stream — used by the
/// command terminal to echo commands and stream a process's output into the
/// same panel. No-op if logging is not initialised.
pub fn append(level: Level, target: &str, message: impl Into<String>) {
if let Some(buffer) = LOG_BUFFER.get() {
if let Ok(mut buffer) = buffer.lock() {
buffer.push(LogLine {
level,
target: target.to_string(),
message: message.into(),
});
}
}
}
/// A logger that mirrors records into [`LOG_BUFFER`] and forwards them to an
/// inner `env_logger` for the terminal.
struct CaptureLogger {
inner: env_logger::Logger,
buffer: Arc<Mutex<LogBuffer>>,
}
impl Log for CaptureLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
self.inner.enabled(metadata)
}
fn log(&self, record: &Record) {
// Honour the env filter for both the terminal and the buffer, so
// RUST_LOG controls the console too.
if !self.inner.enabled(record.metadata()) {
return;
}
if let Ok(mut buffer) = self.buffer.lock() {
buffer.push(LogLine {
level: record.level(),
target: record.target().to_string(),
message: record.args().to_string(),
});
}
self.inner.log(record);
}
fn flush(&self) {
self.inner.flush();
}
}
/// Installs the capturing logger and returns the shared buffer. Mirrors the old
/// `env_logger` setup (honours `RUST_LOG`, default `info`) but also feeds the
/// editor Console. Call once at startup, before any logging.
pub fn init() {
let inner =
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).build();
let max = inner.filter();
let buffer = Arc::new(Mutex::new(LogBuffer::default()));
let _ = LOG_BUFFER.set(buffer.clone());
if log::set_boxed_logger(Box::new(CaptureLogger { inner, buffer })).is_ok() {
log::set_max_level(max);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ring_buffer_evicts_oldest_past_capacity() {
let mut buf = LogBuffer::default();
for i in 0..(CAPACITY + 10) {
buf.push(LogLine {
level: Level::Info,
target: "t".into(),
message: format!("line {i}"),
});
}
assert_eq!(buf.len(), CAPACITY);
// The oldest 10 were evicted, so the first surviving line is "line 10".
assert_eq!(buf.iter().next().unwrap().message, "line 10");
}
#[test]
fn clear_empties_the_buffer() {
let mut buf = LogBuffer::default();
buf.push(LogLine {
level: Level::Warn,
target: "t".into(),
message: "x".into(),
});
assert!(!buf.is_empty());
buf.clear();
assert!(buf.is_empty());
}
}
+140
View File
@@ -0,0 +1,140 @@
//! egui ⇄ engine glue for the editor.
//!
//! The engine core stays UI-agnostic; all egui wiring lives here in the editor.
//! [`EguiLayer`] owns the [`egui_winit`] input state and the [`egui_wgpu`]
//! renderer, translates window events, and paints a built UI into the frame's
//! surface view (recorded with `LoadOp::Load`, so it composites on top of the
//! engine's clear).
use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor};
use egui_winit::State;
use oxide_engine::wgpu;
use oxide_engine::winit::event::WindowEvent;
use oxide_engine::winit::window::Window;
/// Holds the egui input state and GPU renderer for one window.
pub struct EguiLayer {
state: State,
renderer: Renderer,
}
impl EguiLayer {
/// Creates the layer for `window`, building a renderer that targets the
/// given surface format.
pub fn new(
window: &Window,
device: &wgpu::Device,
surface_format: wgpu::TextureFormat,
) -> Self {
let context = egui::Context::default();
let state = State::new(
context,
egui::ViewportId::ROOT,
window,
Some(window.scale_factor() as f32),
None,
None,
);
// Defaults: no MSAA, no depth/stencil, dithering on — matches the
// editor's flat clear-color surface.
let renderer = Renderer::new(device, surface_format, RendererOptions::default());
Self { state, renderer }
}
/// Feeds a window event to egui. Returns `true` if egui consumed it (e.g.
/// a click landed on a panel), so the caller can suppress its own handling.
pub fn on_window_event(&mut self, window: &Window, event: &WindowEvent) -> bool {
self.state.on_window_event(window, event).consumed
}
/// Whether the pointer is currently over a **floating** egui area — a
/// `Window` (Preferences, Layer Names, Groups, …) or other non-background
/// layer — rather than empty space or the background dock.
///
/// The viewport is painted under a transparent dock area (background
/// order), so a geometric "cursor inside the viewport rect" test can't tell
/// that a floating panel is sitting on top of it. The host uses this to
/// suppress viewport orbit/pan/zoom (and stray WASD while typing in a panel
/// that overlaps the viewport).
pub fn pointer_over_floating(&self) -> bool {
let ctx = self.state.egui_ctx();
let Some(pos) = ctx.pointer_latest_pos() else {
return false;
};
ctx.layer_id_at(pos)
.map(|layer| layer.order > egui::Order::Background)
.unwrap_or(false)
}
/// Builds the UI via `build_ui` and paints it into `view`.
///
/// `build_ui` receives the root [`egui::Ui`]; panels are shown inside it
/// (egui 0.34's `show_inside` model). It may be called more than once per
/// frame if egui needs an extra layout pass, so it must be idempotent.
#[allow(clippy::too_many_arguments)]
pub fn paint(
&mut self,
window: &Window,
device: &wgpu::Device,
queue: &wgpu::Queue,
view: &wgpu::TextureView,
size: (u32, u32),
build_ui: impl FnMut(&mut egui::Ui),
) {
let raw_input = self.state.take_egui_input(window);
let context = self.state.egui_ctx().clone();
let output = context.run_ui(raw_input, build_ui);
self.state
.handle_platform_output(window, output.platform_output);
let primitives = context.tessellate(output.shapes, output.pixels_per_point);
let screen = ScreenDescriptor {
size_in_pixels: [size.0.max(1), size.1.max(1)],
pixels_per_point: output.pixels_per_point,
};
for (id, delta) in &output.textures_delta.set {
self.renderer.update_texture(device, queue, *id, delta);
}
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("oxide.editor.egui.encoder"),
});
// egui may emit its own command buffers (for paint callbacks); submit
// those ahead of our pass.
let user_buffers =
self.renderer
.update_buffers(device, queue, &mut encoder, &primitives, &screen);
{
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("oxide.editor.egui.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
// Load: keep the engine's clear; draw the UI over it.
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// egui-wgpu wants a 'static pass; the encoder outlives it here.
let mut pass = pass.forget_lifetime();
self.renderer.render(&mut pass, &primitives, &screen);
}
for id in &output.textures_delta.free {
self.renderer.free_texture(id);
}
queue.submit(
user_buffers
.into_iter()
.chain(std::iter::once(encoder.finish())),
);
}
}
+655
View File
@@ -0,0 +1,655 @@
//! Module → editor extension API.
//!
//! The engine's [`Module`](oxide_engine::app::Module) trait registers systems,
//! component types, asset loaders, and resources on an
//! [`App`](oxide_engine::app::App). This module is its **editor-side companion**:
//! one trait — [`EditorModule`] — through which a module contributes the UI it
//! needs the editor to host on its behalf.
//!
//! Specifically, a module can add:
//!
//! - **Menu items** in the top menu bar (e.g. `"File/Open Recent"`),
//! - **Dockable panels** in the docking shell (e.g. an "Audio Mixer"),
//! - **Viewport tools** that take over input on the 3D viewport (gizmos,
//! measurement, paint),
//! - **Component inspectors** that render rich editors for the module's
//! component types (keyed by their
//! [`TypeRegistry`](oxide_engine::reflect::TypeRegistry) name), and
//! - **Settings pages** that drive the module's
//! [`Settings`](oxide_engine::settings::Settings) section in the Preferences
//! window.
//!
//! All five plug into the editor through one registry — [`EditorExtensions`] —
//! consumed by the docking shell. The shell never edits its own source to host
//! a new module's UI; this is *the* mechanism by which "anyone can write a
//! module" that extends both engine logic and the editor.
//!
//! ## Why a separate trait
//!
//! The engine has no egui dependency, so the editor hook can't live on the
//! engine's `Module` trait without dragging UI types into the engine. Two
//! traits implemented on the same struct keeps the engine GUI-free and lets the
//! editor binary register the same module on both sides:
//!
//! ```ignore
//! struct MyModule;
//! impl oxide_engine::app::Module for MyModule { /* … systems, types */ }
//! impl oxide_editor::extension::EditorModule for MyModule { /* … panels */ }
//! ```
//!
//! ## Attribution
//!
//! Every contribution remembers which module added it. Removing a module
//! ([`EditorExtensions::remove_module`]) removes all of its contributions in
//! one shot — the same lifecycle the engine's
//! [`App::remove_module`](oxide_engine::app::App::remove_module) gives systems,
//! types, and loaders. Disabling a module
//! ([`set_module_enabled`](EditorExtensions::set_module_enabled)) keeps the
//! contributions registered but hides them from the shell, so toggling a
//! module in Preferences is reversible without rebuilding the registry.
//!
//! ## Render closures
//!
//! Panel / inspector / settings-page closures take only `&mut egui::Ui` in
//! Stage 6 piece 5 (registration). Piece 6 — the docking shell — refines the
//! signatures to pass through the editor's runtime context. Modules that need
//! shared state today should capture it through interior mutability
//! (`Rc<RefCell<...>>`).
use std::collections::BTreeMap;
/// Where a panel prefers to be docked the first time the user opens it.
///
/// The shell may override this when restoring a saved layout; it is only a
/// hint, not a guarantee.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DockLocation {
/// Pinned to the left side of the main area (hierarchies, project browser).
Left,
/// Pinned to the right side (properties / inspector).
Right,
/// Pinned to the bottom (console, logs, timeline).
Bottom,
/// The main central tab area (viewport, code, asset preview).
Center,
/// A floating window outside the dock layout.
Floating,
}
/// One top-menu-bar item contributed by a module.
///
/// `path` uses `/` as a separator and identifies the menu tree, e.g.
/// `"File/New Project"` or `"View/Layout/Default"`. The shell groups items by
/// their leading segments.
pub struct MenuItem {
/// Slash-separated path through the menu tree.
pub path: String,
/// Optional human-readable shortcut hint (e.g. `"Ctrl+N"`). Not bound by
/// this API — the actual key binding lives in the Stage-7 input map.
pub shortcut: Option<String>,
/// Invoked when the item is clicked. The shell decides when to call it.
pub action: Box<dyn FnMut()>,
}
/// A dockable panel contributed by a module.
pub struct Panel {
/// Stable name; doubles as the tab title and the lookup key.
pub name: String,
/// Where the panel prefers to dock initially.
pub default_dock: DockLocation,
/// Renders the panel's contents into `ui` each frame the panel is visible.
pub render: Box<dyn FnMut(&mut egui::Ui)>,
}
/// A viewport tool — usually a gizmo or a brush — that takes over the 3D
/// viewport's input while active.
pub struct ViewportTool {
/// Stable name (e.g. `"Translate"`, `"Sculpt"`); identifies the tool in
/// menus, toolbars, and shortcut tables.
pub name: String,
/// Called once when the tool becomes the active viewport tool. Use it to
/// reset transient state or hook into the editor's command stack.
pub on_activate: Box<dyn FnMut()>,
}
/// An editor for one reflected component type, keyed by the same name the
/// component is registered under in the
/// [`TypeRegistry`](oxide_engine::reflect::TypeRegistry). The shell calls
/// `render` from the Inspector panel when a selected entity has the component.
pub struct ComponentInspector {
/// Matches the `name` passed to
/// [`App::register_type`](oxide_engine::app::App::register_type).
pub type_name: String,
/// Renders an editor for the component into `ui`.
pub render: Box<dyn FnMut(&mut egui::Ui)>,
}
/// A page in the Preferences window driving one
/// [`Settings`](oxide_engine::settings::Settings) section.
pub struct SettingsPage {
/// Matches the `name` passed to
/// [`Settings::register`](oxide_engine::settings::Settings::register).
pub section_name: String,
/// Title shown in the Preferences sidebar (defaults to `section_name` when
/// the contributor leaves it empty).
pub title: String,
/// Renders the page's controls into `ui`.
pub render: Box<dyn FnMut(&mut egui::Ui)>,
}
/// Editor-side companion to the engine's
/// [`Module`](oxide_engine::app::Module) trait.
///
/// Implement on the same type that implements `Module` (or on a separate
/// editor-only struct) and pass it to
/// [`EditorExtensions::add_module`]. Everything `build_editor` registers is
/// attributed to this module and can be removed atomically with
/// [`EditorExtensions::remove_module`].
pub trait EditorModule: 'static {
/// A stable, unique name — should match the paired engine `Module::name`
/// when both halves describe the same module, so the editor and engine
/// agree on enable/disable.
fn name(&self) -> &'static str;
/// Registers UI contributions on `ext`.
fn build_editor(&self, ext: &mut EditorExtensions);
}
/// Internal record tying any contribution to its source module and an
/// enabled/disabled flag inherited from the module.
struct Entry<T> {
module: &'static str,
value: T,
}
/// Registry of every UI contribution made by every editor module. The docking
/// shell reads this in Piece 6 to assemble the menu bar, dock layout, viewport
/// toolbox, inspector, and Preferences window.
#[derive(Default)]
pub struct EditorExtensions {
menu_items: Vec<Entry<MenuItem>>,
panels: Vec<Entry<Panel>>,
viewport_tools: Vec<Entry<ViewportTool>>,
inspectors: BTreeMap<String, Entry<ComponentInspector>>,
settings_pages: BTreeMap<String, Entry<SettingsPage>>,
modules: Vec<&'static str>,
enabled: BTreeMap<&'static str, bool>,
/// Set only while a module's `build_editor` is running, so individual
/// `add_*` helpers can attribute the contribution without taking the
/// module name as an argument.
current_module: Option<&'static str>,
}
impl EditorExtensions {
/// A fresh, empty registry.
pub fn new() -> Self {
Self::default()
}
/// Registers `module` and runs its
/// [`build_editor`](EditorModule::build_editor). Re-adding a module with
/// the same name first removes the old one, so callers don't have to dance
/// around stale contributions when reloading.
pub fn add_module<M: EditorModule>(&mut self, module: M) {
let name = module.name();
if self.modules.contains(&name) {
self.remove_module(name);
}
self.modules.push(name);
self.enabled.insert(name, true);
self.current_module = Some(name);
module.build_editor(self);
self.current_module = None;
}
/// Removes every contribution registered by the named module. Returns
/// whether the module was present.
pub fn remove_module(&mut self, name: &str) -> bool {
if !self.modules.contains(&name) {
return false;
}
self.menu_items.retain(|e| e.module != name);
self.panels.retain(|e| e.module != name);
self.viewport_tools.retain(|e| e.module != name);
self.inspectors.retain(|_, e| e.module != name);
self.settings_pages.retain(|_, e| e.module != name);
self.modules.retain(|m| *m != name);
self.enabled.remove(name);
true
}
/// Whether the named module is currently registered (independent of
/// enabled-state).
pub fn has_module(&self, name: &str) -> bool {
self.modules.contains(&name)
}
/// Toggles whether contributions from the named module are visible to the
/// shell. The contributions stay registered so re-enabling is instant.
/// Returns whether the module was present.
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
if let Some(slot) = self.enabled.get_mut(name) {
*slot = enabled;
true
} else {
false
}
}
/// Whether the named module's contributions are currently enabled. Returns
/// `false` for unknown modules.
pub fn is_module_enabled(&self, name: &str) -> bool {
self.enabled.get(name).copied().unwrap_or(false)
}
/// Registered module names, in insertion order.
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
self.modules.iter().copied()
}
// --- contribution helpers (called from `build_editor`) -----------------
/// Adds a menu item. Panics if called outside a module's `build_editor` —
/// every contribution must be attributable to some module.
pub fn add_menu_item(
&mut self,
path: impl Into<String>,
action: impl FnMut() + 'static,
) -> &mut Self {
self.add_menu_item_full(MenuItem {
path: path.into(),
shortcut: None,
action: Box::new(action),
})
}
/// Adds a menu item with a fully-specified [`MenuItem`] (lets the caller
/// set a shortcut hint).
pub fn add_menu_item_full(&mut self, item: MenuItem) -> &mut Self {
let module = self.expect_module("add_menu_item");
self.menu_items.push(Entry {
module,
value: item,
});
self
}
/// Adds a dockable panel. `default_dock` is a placement hint; the shell
/// may override when restoring a saved layout.
pub fn add_panel(
&mut self,
name: impl Into<String>,
default_dock: DockLocation,
render: impl FnMut(&mut egui::Ui) + 'static,
) -> &mut Self {
let module = self.expect_module("add_panel");
self.panels.push(Entry {
module,
value: Panel {
name: name.into(),
default_dock,
render: Box::new(render),
},
});
self
}
/// Adds a viewport tool (gizmo, brush, …).
pub fn add_viewport_tool(
&mut self,
name: impl Into<String>,
on_activate: impl FnMut() + 'static,
) -> &mut Self {
let module = self.expect_module("add_viewport_tool");
self.viewport_tools.push(Entry {
module,
value: ViewportTool {
name: name.into(),
on_activate: Box::new(on_activate),
},
});
self
}
/// Adds a component inspector keyed by the type's reflection name.
/// Re-registering a name overwrites the previous inspector (most-recently-
/// added module wins; this lets a project override a base module's
/// inspector if it has reason to).
pub fn add_inspector(
&mut self,
type_name: impl Into<String>,
render: impl FnMut(&mut egui::Ui) + 'static,
) -> &mut Self {
let module = self.expect_module("add_inspector");
let type_name = type_name.into();
self.inspectors.insert(
type_name.clone(),
Entry {
module,
value: ComponentInspector {
type_name,
render: Box::new(render),
},
},
);
self
}
/// Adds a Preferences page driving the named settings section.
pub fn add_settings_page(
&mut self,
section_name: impl Into<String>,
title: impl Into<String>,
render: impl FnMut(&mut egui::Ui) + 'static,
) -> &mut Self {
let module = self.expect_module("add_settings_page");
let section_name = section_name.into();
let title = title.into();
let title = if title.is_empty() {
section_name.clone()
} else {
title
};
self.settings_pages.insert(
section_name.clone(),
Entry {
module,
value: SettingsPage {
section_name,
title,
render: Box::new(render),
},
},
);
self
}
// --- shell-facing lookups ---------------------------------------------
/// Slash-separated paths of every currently-enabled menu item, in the
/// order they were contributed.
pub fn menu_item_paths(&self) -> impl Iterator<Item = &str> {
self.iter_menu_items().map(|i| i.path.as_str())
}
/// Names of every currently-enabled panel.
pub fn panel_names(&self) -> impl Iterator<Item = &str> {
self.iter_panels().map(|p| p.name.as_str())
}
/// Names of every currently-enabled viewport tool.
pub fn viewport_tool_names(&self) -> impl Iterator<Item = &str> {
self.iter_viewport_tools().map(|t| t.name.as_str())
}
/// Reflection-keyed type names that currently have an inspector
/// registered.
pub fn inspector_type_names(&self) -> impl Iterator<Item = &str> {
self.iter_inspectors().map(|i| i.type_name.as_str())
}
/// Settings-section names that currently have a Preferences page
/// registered.
pub fn settings_page_names(&self) -> impl Iterator<Item = &str> {
self.iter_settings_pages().map(|p| p.section_name.as_str())
}
/// Whether an inspector is registered for the given reflection name and
/// the contributing module is enabled.
pub fn has_inspector_for(&self, type_name: &str) -> bool {
self.inspectors
.get(type_name)
.map(|e| self.is_enabled(e.module))
.unwrap_or(false)
}
/// Whether a Preferences page is registered for the given section name
/// and the contributing module is enabled.
pub fn has_settings_page_for(&self, section_name: &str) -> bool {
self.settings_pages
.get(section_name)
.map(|e| self.is_enabled(e.module))
.unwrap_or(false)
}
/// Iterates the enabled menu items themselves (gives the shell direct
/// access to actions/shortcuts when rendering).
pub fn iter_menu_items(&self) -> impl Iterator<Item = &MenuItem> {
self.menu_items
.iter()
.filter(|e| self.is_enabled(e.module))
.map(|e| &e.value)
}
/// Mutably iterates the enabled menu items so the shell can invoke each
/// item's `FnMut` action when the user clicks it.
pub fn iter_menu_items_mut(&mut self) -> impl Iterator<Item = &mut MenuItem> {
let enabled = &self.enabled;
self.menu_items
.iter_mut()
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
.map(|e| &mut e.value)
}
/// Iterates the enabled panels.
pub fn iter_panels(&self) -> impl Iterator<Item = &Panel> {
self.panels
.iter()
.filter(|e| self.is_enabled(e.module))
.map(|e| &e.value)
}
/// Mutably iterates the enabled panels so the shell can call each panel's
/// `FnMut` render closure each frame.
pub fn iter_panels_mut(&mut self) -> impl Iterator<Item = &mut Panel> {
let enabled = &self.enabled;
self.panels
.iter_mut()
.filter(move |e| enabled.get(e.module).copied().unwrap_or(false))
.map(|e| &mut e.value)
}
/// Iterates the enabled viewport tools.
pub fn iter_viewport_tools(&self) -> impl Iterator<Item = &ViewportTool> {
self.viewport_tools
.iter()
.filter(|e| self.is_enabled(e.module))
.map(|e| &e.value)
}
/// Iterates the enabled component inspectors (in stable name order).
pub fn iter_inspectors(&self) -> impl Iterator<Item = &ComponentInspector> {
self.inspectors
.values()
.filter(|e| self.is_enabled(e.module))
.map(|e| &e.value)
}
/// Iterates the enabled settings pages (in stable section-name order).
pub fn iter_settings_pages(&self) -> impl Iterator<Item = &SettingsPage> {
self.settings_pages
.values()
.filter(|e| self.is_enabled(e.module))
.map(|e| &e.value)
}
/// The total number of contributions of every kind, across every
/// registered module. Mostly for tests and diagnostics.
pub fn contribution_count(&self) -> usize {
self.menu_items.len()
+ self.panels.len()
+ self.viewport_tools.len()
+ self.inspectors.len()
+ self.settings_pages.len()
}
fn is_enabled(&self, module: &str) -> bool {
self.enabled.get(module).copied().unwrap_or(false)
}
fn expect_module(&self, helper: &str) -> &'static str {
self.current_module.unwrap_or_else(|| {
panic!("EditorExtensions::{helper} called outside a module's build_editor")
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal module that exercises every contribution kind. Used both by
/// the unit tests here and by the integration test in `tests/src/lib.rs`
/// (where it proves the Stage-6 criterion: a module adds a menu item, a
/// panel, and a settings page through the public API with no editor-core
/// edits).
struct DemoModule;
impl EditorModule for DemoModule {
fn name(&self) -> &'static str {
"demo"
}
fn build_editor(&self, ext: &mut EditorExtensions) {
ext.add_menu_item("Demo/Hello", || {});
ext.add_panel("Demo Panel", DockLocation::Right, |_ui| {});
ext.add_viewport_tool("Demo Tool", || {});
ext.add_inspector("DemoComponent", |_ui| {});
ext.add_settings_page("demo", "Demo", |_ui| {});
}
}
struct OverlapModule;
impl EditorModule for OverlapModule {
fn name(&self) -> &'static str {
"overlap"
}
fn build_editor(&self, ext: &mut EditorExtensions) {
ext.add_menu_item("File/Quit", || {});
ext.add_inspector("DemoComponent", |_ui| {});
}
}
#[test]
fn add_module_registers_each_contribution_kind() {
let mut ext = EditorExtensions::new();
ext.add_module(DemoModule);
assert!(ext.has_module("demo"));
assert!(ext.is_module_enabled("demo"));
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
assert_eq!(
ext.menu_item_paths().collect::<Vec<_>>(),
vec!["Demo/Hello"]
);
assert_eq!(ext.panel_names().collect::<Vec<_>>(), vec!["Demo Panel"]);
assert_eq!(
ext.viewport_tool_names().collect::<Vec<_>>(),
vec!["Demo Tool"]
);
assert!(ext.has_inspector_for("DemoComponent"));
assert!(ext.has_settings_page_for("demo"));
assert_eq!(ext.contribution_count(), 5);
}
#[test]
fn remove_module_drops_every_contribution() {
let mut ext = EditorExtensions::new();
ext.add_module(DemoModule);
assert_eq!(ext.contribution_count(), 5);
assert!(ext.remove_module("demo"));
assert!(!ext.has_module("demo"));
assert_eq!(ext.contribution_count(), 0);
assert!(!ext.has_inspector_for("DemoComponent"));
assert!(!ext.has_settings_page_for("demo"));
// Removing twice is a no-op.
assert!(!ext.remove_module("demo"));
}
#[test]
fn disabling_a_module_hides_its_contributions_without_removing() {
let mut ext = EditorExtensions::new();
ext.add_module(DemoModule);
assert!(ext.set_module_enabled("demo", false));
assert!(!ext.is_module_enabled("demo"));
// Hidden from every shell-facing lookup…
assert_eq!(ext.menu_item_paths().count(), 0);
assert_eq!(ext.panel_names().count(), 0);
assert!(!ext.has_inspector_for("DemoComponent"));
assert!(!ext.has_settings_page_for("demo"));
// …but still registered, so re-enabling is instant.
assert_eq!(ext.contribution_count(), 5);
assert!(ext.set_module_enabled("demo", true));
assert_eq!(ext.panel_names().count(), 1);
}
#[test]
fn re_adding_a_module_replaces_its_contributions() {
let mut ext = EditorExtensions::new();
ext.add_module(DemoModule);
ext.add_module(DemoModule);
// Still one module, contributions are not duplicated.
assert_eq!(ext.modules().collect::<Vec<_>>(), vec!["demo"]);
assert_eq!(ext.contribution_count(), 5);
}
#[test]
fn later_module_overrides_inspector_for_same_type() {
// Both modules register an inspector for "DemoComponent". The
// last-registered wins, but attribution remains correct: removing the
// override exposes nothing (the original was overwritten, not
// stacked), which is the simple-and-predictable behavior to ship for
// piece 5. Stacking would let a project layer multiple inspectors on
// one type — possible future refinement, not needed now.
let mut ext = EditorExtensions::new();
ext.add_module(DemoModule);
ext.add_module(OverlapModule);
assert!(ext.has_inspector_for("DemoComponent"));
let owners: Vec<&'static str> = ext.inspectors.values().map(|e| e.module).collect();
assert_eq!(owners, vec!["overlap"]);
}
#[test]
fn modules_dont_see_each_others_contributions_when_disabled() {
let mut ext = EditorExtensions::new();
ext.add_module(DemoModule);
ext.add_module(OverlapModule);
// Two menu items total; disabling overlap hides only its item.
assert_eq!(ext.menu_item_paths().count(), 2);
ext.set_module_enabled("overlap", false);
let visible: Vec<&str> = ext.menu_item_paths().collect();
assert_eq!(visible, vec!["Demo/Hello"]);
}
#[test]
#[should_panic(expected = "outside a module's build_editor")]
fn contributing_outside_build_editor_panics() {
// Catches the easy mistake of calling add_panel on a bare
// EditorExtensions — every contribution must be attributable to a
// module, otherwise remove_module would leave orphans behind.
let mut ext = EditorExtensions::new();
ext.add_panel("Orphan", DockLocation::Center, |_ui| {});
}
#[test]
fn settings_page_defaults_title_to_section_name() {
struct M;
impl EditorModule for M {
fn name(&self) -> &'static str {
"m"
}
fn build_editor(&self, ext: &mut EditorExtensions) {
ext.add_settings_page("audio", "", |_ui| {});
}
}
let mut ext = EditorExtensions::new();
ext.add_module(M);
let page = ext.iter_settings_pages().next().unwrap();
assert_eq!(page.title, "audio");
}
}
+886
View File
@@ -0,0 +1,886 @@
//! Transform gizmo math: hit testing, drag projection, and snap rounding.
//!
//! Stage 7 piece 6 (a): rays in, transforms out. The viewport piece
//! renders the handles and feeds rays into [`hit_test`] and
//! [`apply_drag`]; this module owns the geometry so all of it can be
//! unit-tested without a window.
//!
//! Three modes ([`GizmoMode`]) each expose a small set of [`GizmoHandle`]s:
//!
//! - **Translate** — one axis arrow per world axis, plus three "plane
//! quads" (XY/XZ/YZ) that drag along two axes at once.
//! - **Rotate** — one circle per world axis, dragged around its normal.
//! - **Scale** — one axis cube per world axis (non-uniform along that
//! axis) plus one center handle for uniform scale.
//!
//! Holding the snap modifier rounds the drag result to a configurable
//! step ([`SnapSettings`]): grid distance for translate, angle for
//! rotate, factor step for scale. Snap is applied to the *delta* from
//! the drag's starting transform, never to the starting transform
//! itself, so the result lines up with a fresh selection that already
//! sits between grid points.
//!
//! The gizmo lives at the entity's translation (its rotation and scale
//! do not transform the handles — they always point along world axes).
//! The shipped viewport renders this "world-space" gizmo; a future
//! "local-space" toggle would orient the handles by the entity rotation
//! before hit testing, which is a small change in [`world_axis`] /
//! [`world_plane`].
use oxide_engine::math::{Plane, Quat, Ray, Transform, Vec3};
/// Which transform tool the gizmo is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GizmoMode {
/// Axis arrows + plane quads. Hotkey **W**.
Translate,
/// Axis circles. Hotkey **E**.
Rotate,
/// Axis cubes + center uniform. Hotkey **R**.
Scale,
}
impl GizmoMode {
/// The label shown in the status bar / toolbar.
pub fn label(self) -> &'static str {
match self {
GizmoMode::Translate => "Translate",
GizmoMode::Rotate => "Rotate",
GizmoMode::Scale => "Scale",
}
}
}
/// One of the three world axes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis3 {
X,
Y,
Z,
}
impl Axis3 {
/// All three axes in stable order.
pub const ALL: [Axis3; 3] = [Axis3::X, Axis3::Y, Axis3::Z];
/// Unit vector along this axis.
pub fn unit(self) -> Vec3 {
match self {
Axis3::X => Vec3::X,
Axis3::Y => Vec3::Y,
Axis3::Z => Vec3::Z,
}
}
/// Zero-based index for indexing into per-component arrays.
pub fn index(self) -> usize {
match self {
Axis3::X => 0,
Axis3::Y => 1,
Axis3::Z => 2,
}
}
}
/// One of the three world-aligned planes (XY = plane whose normal is Z, …).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaneAxis {
XY,
XZ,
YZ,
}
impl PlaneAxis {
/// All three planes in stable order.
pub const ALL: [PlaneAxis; 3] = [PlaneAxis::XY, PlaneAxis::XZ, PlaneAxis::YZ];
/// Unit normal to the plane.
pub fn normal(self) -> Vec3 {
match self {
PlaneAxis::XY => Vec3::Z,
PlaneAxis::XZ => Vec3::Y,
PlaneAxis::YZ => Vec3::X,
}
}
/// The two axes that lie in this plane (in stable order).
pub fn axes(self) -> (Vec3, Vec3) {
match self {
PlaneAxis::XY => (Vec3::X, Vec3::Y),
PlaneAxis::XZ => (Vec3::X, Vec3::Z),
PlaneAxis::YZ => (Vec3::Y, Vec3::Z),
}
}
}
/// One interactive gizmo handle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GizmoHandle {
TranslateAxis(Axis3),
TranslatePlane(PlaneAxis),
RotateAxis(Axis3),
ScaleAxis(Axis3),
/// The center "uniform scale" cube.
ScaleUniform,
}
impl GizmoHandle {
/// The mode this handle belongs to.
pub fn mode(self) -> GizmoMode {
match self {
GizmoHandle::TranslateAxis(_) | GizmoHandle::TranslatePlane(_) => GizmoMode::Translate,
GizmoHandle::RotateAxis(_) => GizmoMode::Rotate,
GizmoHandle::ScaleAxis(_) | GizmoHandle::ScaleUniform => GizmoMode::Scale,
}
}
}
/// Snap step sizes applied during a drag while the snap modifier is held.
///
/// Each step is applied to the **delta** the drag has accumulated — never
/// to the starting transform — so a selection that already sits between
/// grid points keeps its starting offset.
#[derive(Debug, Clone, Copy)]
pub struct SnapSettings {
/// Translation grid in world units (default `0.25`).
pub distance: f32,
/// Rotation step in degrees (default `15`).
pub angle_deg: f32,
/// Scale step (default `0.1` — factors round to the nearest `0.1`).
pub scale: f32,
}
impl Default for SnapSettings {
fn default() -> Self {
Self {
distance: 0.25,
angle_deg: 15.0,
scale: 0.1,
}
}
}
/// One in-progress gizmo drag.
///
/// Created by the viewport when the user clicks a handle, kept alive while
/// the button is held, and dropped on release. Each frame the viewport
/// calls [`apply_drag`] with the new pointer ray to compute the new
/// transform.
#[derive(Debug, Clone, Copy)]
pub struct GizmoDrag {
/// The handle the user grabbed.
pub handle: GizmoHandle,
/// The entity's transform when the drag started — never mutated; the
/// drag computes a delta from this and applies it fresh each frame.
pub start_transform: Transform,
/// The world-space point where the drag began. For an axis handle
/// this is the closest point on the axis to the click ray; for a
/// plane handle, the ray-plane intersection; for a circle handle,
/// the projection of the ray hit onto the rotation plane.
pub start_anchor: Vec3,
/// Handle-specific reference scalar set at drag start. For
/// [`GizmoHandle::ScaleUniform`] it is the world-space distance that
/// corresponds to one *factor* of change — the gizmo size — so a
/// drag away from the entity by that much grows the scale by ~1.0.
/// Unused (set to `1.0`) for every other handle.
pub reference: f32,
}
// =====================================================================
// Hit testing
// =====================================================================
/// Tries every handle the given mode exposes and returns the one closest
/// to `ray`, or `None` if none are within `pixel_tolerance_world` of any
/// handle. `gizmo_size` is the per-axis world length of the arrow / cube
/// handles; both inputs are computed by the viewport based on the
/// camera's distance to the gizmo origin (so the gizmo stays the same
/// pixel size at any zoom).
pub fn hit_test(
ray: &Ray,
transform: &Transform,
mode: GizmoMode,
gizmo_size: f32,
pixel_tolerance_world: f32,
) -> Option<GizmoHandle> {
let origin = transform.translation;
let mut best: Option<(f32, GizmoHandle)> = None;
let mut consider = |dist_sq: f32, handle: GizmoHandle| {
if dist_sq.is_finite() && dist_sq < pixel_tolerance_world * pixel_tolerance_world {
match best {
Some((b, _)) if b <= dist_sq => {}
_ => best = Some((dist_sq, handle)),
}
}
};
match mode {
GizmoMode::Translate => {
for axis in Axis3::ALL {
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
consider(d, GizmoHandle::TranslateAxis(axis));
}
for plane in PlaneAxis::ALL {
if let Some(d) = plane_quad_distance_sq(origin, plane, gizmo_size, ray) {
consider(d, GizmoHandle::TranslatePlane(plane));
}
}
}
GizmoMode::Rotate => {
for axis in Axis3::ALL {
if let Some(d) = circle_distance_sq(origin, axis.unit(), gizmo_size, ray) {
consider(d, GizmoHandle::RotateAxis(axis));
}
}
}
GizmoMode::Scale => {
for axis in Axis3::ALL {
let d = axis_segment_distance_sq(origin, axis.unit(), gizmo_size, ray);
consider(d, GizmoHandle::ScaleAxis(axis));
}
// Uniform handle: the center cube.
let d = ray.distance_to_point(origin).powi(2);
consider(d, GizmoHandle::ScaleUniform);
}
}
best.map(|(_, h)| h)
}
/// Squared distance from `ray` to the segment from `origin + axis * inner`
/// to `origin + axis * length`, with the closest point clamped to the
/// segment. Used for axis arrows.
///
/// The leading `inner` offset (~20% of length) keeps the segment clear of
/// the central cube area, so a ray that pierces the gizmo's center is
/// claimed by the uniform / center handle rather than by every axis at
/// once.
fn axis_segment_distance_sq(origin: Vec3, axis: Vec3, length: f32, ray: &Ray) -> f32 {
let inner = length * 0.2;
let pt_on_axis = closest_point_on_line(origin, axis, ray);
let along = (pt_on_axis - origin).dot(axis).clamp(inner, length);
let clamped = origin + axis * along;
ray.distance_to_point(clamped).powi(2)
}
/// Distance² from `ray` to a square plane quad at `origin` (size × size),
/// or `None` when the ray is parallel to the plane. Used for translate
/// plane handles.
fn plane_quad_distance_sq(origin: Vec3, plane: PlaneAxis, size: f32, ray: &Ray) -> Option<f32> {
let p = Plane::from_point_normal(origin, plane.normal());
let t = p.ray_intersection(ray)?;
let hit = ray.at(t);
let (a, b) = plane.axes();
// The plane quad spans roughly the *outer* part of the gizmo: from
// ~0.3*size to ~0.7*size on each axis, away from the central cube
// and clear of the axis arrows.
let inner = size * 0.3;
let outer = size * 0.7;
let da = (hit - origin).dot(a);
let db = (hit - origin).dot(b);
if da >= inner && da <= outer && db >= inner && db <= outer {
// Inside the quad — perfect hit, no distance penalty.
Some(0.0)
} else {
// Outside — penalize by distance from the nearest edge so handle
// priority degrades smoothly with miss distance.
let clamped = origin + a * da.clamp(inner, outer) + b * db.clamp(inner, outer);
Some(ray.distance_to_point(clamped).powi(2))
}
}
/// Distance² from `ray` to the circle of radius `r` lying in the plane
/// through `origin` with the given `axis` as normal, or `None` when the
/// ray is parallel to the plane. Used for rotate circles.
fn circle_distance_sq(origin: Vec3, axis: Vec3, r: f32, ray: &Ray) -> Option<f32> {
let p = Plane::from_point_normal(origin, axis);
let t = p.ray_intersection(ray)?;
let hit = ray.at(t);
// Project onto the plane and find the closest circle point.
let v = hit - origin;
let in_plane = v - axis * v.dot(axis);
let len = in_plane.length();
if len < 1e-6 {
// Right at the center — distance to circle is `r` itself.
return Some(r * r);
}
let on_circle = origin + in_plane * (r / len);
Some(ray.distance_to_point(on_circle).powi(2))
}
/// Closest point on the line through `origin` along the unit `dir`
/// vector to `ray`. Result is unconstrained — clamping to a segment is
/// the caller's job.
pub fn closest_point_on_line(origin: Vec3, dir: Vec3, ray: &Ray) -> Vec3 {
let r = ray.direction;
let w = origin - ray.origin;
let d = dir.dot(r);
let denom = 1.0 - d * d;
if denom.abs() < 1e-6 {
// Ray parallel to line — closest point on the line is the origin.
return origin;
}
let s = (dir.dot(-w) - r.dot(-w) * d) / denom;
origin + dir * s
}
// =====================================================================
// Drag application
// =====================================================================
/// Applies the in-progress `drag` to its starting transform using the
/// pointer's current ray, returning the new transform. Pure: same inputs
/// always yield the same output.
///
/// When `snap` is `Some`, the per-mode delta is rounded to the appropriate
/// step before being applied (so the snap modifier can be toggled mid-
/// drag and the result lines up to the grid regardless of how the user
/// got there).
pub fn apply_drag(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
match drag.handle {
GizmoHandle::TranslateAxis(axis) => translate_along_axis(drag, current_ray, axis, snap),
GizmoHandle::TranslatePlane(plane) => translate_in_plane(drag, current_ray, plane, snap),
GizmoHandle::RotateAxis(axis) => rotate_around_axis(drag, current_ray, axis, snap),
GizmoHandle::ScaleAxis(axis) => scale_along_axis(drag, current_ray, axis, snap),
GizmoHandle::ScaleUniform => scale_uniform(drag, current_ray, snap),
}
}
/// Rounds `value` to the nearest integer multiple of `step`. Returns
/// `value` unchanged when `step` is non-positive.
pub fn snap_round(value: f32, step: f32) -> f32 {
if step <= 0.0 {
return value;
}
(value / step).round() * step
}
fn translate_along_axis(
drag: &GizmoDrag,
current_ray: &Ray,
axis: Axis3,
snap: Option<&SnapSettings>,
) -> Transform {
let dir = axis.unit();
let now = closest_point_on_line(drag.start_transform.translation, dir, current_ray);
let mut delta = (now - drag.start_anchor).dot(dir);
if let Some(s) = snap {
delta = snap_round(delta, s.distance);
}
let mut t = drag.start_transform;
t.translation += dir * delta;
t
}
fn translate_in_plane(
drag: &GizmoDrag,
current_ray: &Ray,
plane: PlaneAxis,
snap: Option<&SnapSettings>,
) -> Transform {
let p = Plane::from_point_normal(drag.start_transform.translation, plane.normal());
let Some(t) = p.ray_intersection(current_ray) else {
return drag.start_transform;
};
let now = current_ray.at(t);
let (a, b) = plane.axes();
let mut da = (now - drag.start_anchor).dot(a);
let mut db = (now - drag.start_anchor).dot(b);
if let Some(s) = snap {
da = snap_round(da, s.distance);
db = snap_round(db, s.distance);
}
let mut out = drag.start_transform;
out.translation += a * da + b * db;
out
}
fn rotate_around_axis(
drag: &GizmoDrag,
current_ray: &Ray,
axis: Axis3,
snap: Option<&SnapSettings>,
) -> Transform {
let axis_dir = axis.unit();
let origin = drag.start_transform.translation;
let plane = Plane::from_point_normal(origin, axis_dir);
let Some(t) = plane.ray_intersection(current_ray) else {
return drag.start_transform;
};
let now = current_ray.at(t);
// Vectors from origin to start / current points, both already lying
// in the rotation plane.
let from = (drag.start_anchor - origin).normalize_or_zero();
let to = (now - origin).normalize_or_zero();
if from.length_squared() < 1e-6 || to.length_squared() < 1e-6 {
return drag.start_transform;
}
// Signed angle around `axis_dir`.
let cross = from.cross(to);
let sin = cross.dot(axis_dir);
let cos = from.dot(to).clamp(-1.0, 1.0);
let mut angle = sin.atan2(cos);
if let Some(s) = snap {
let step = s.angle_deg.to_radians();
angle = snap_round(angle, step);
}
let rotation = Quat::from_axis_angle(axis_dir, angle);
let mut out = drag.start_transform;
out.rotation = rotation * drag.start_transform.rotation;
out
}
fn scale_along_axis(
drag: &GizmoDrag,
current_ray: &Ray,
axis: Axis3,
snap: Option<&SnapSettings>,
) -> Transform {
let dir = axis.unit();
let origin = drag.start_transform.translation;
let now = closest_point_on_line(origin, dir, current_ray);
let start_along = (drag.start_anchor - origin).dot(dir);
if start_along.abs() < 1e-4 {
return drag.start_transform;
}
let now_along = (now - origin).dot(dir);
let mut factor = now_along / start_along;
if let Some(s) = snap {
factor = snap_round(factor, s.scale);
}
// Clamp to a small positive floor so a runaway drag can't flip scale
// to zero / negative (which crashes inverse-transform math elsewhere).
factor = factor.max(0.001);
let mut out = drag.start_transform;
let mut s = drag.start_transform.scale.to_array();
s[axis.index()] *= factor;
out.scale = Vec3::from_array(s);
out
}
fn scale_uniform(drag: &GizmoDrag, current_ray: &Ray, snap: Option<&SnapSettings>) -> Transform {
let origin = drag.start_transform.translation;
// Perpendicular distance from the current ray to the entity, in world
// units. The *delta* from the click's perpendicular distance, divided
// by `drag.reference` (the gizmo size), is the additive change in
// scale factor. Avoids the previous `now_dist / start_dist` formula's
// blow-up when the click landed near the gizmo center (start_dist
// ≈ 0) and the divide spiked the factor.
let start_perp = (drag.start_anchor - origin).length();
let now_perp = current_ray.distance_to_point(origin);
let reference = drag.reference.max(1e-4);
let mut factor = 1.0 + (now_perp - start_perp) / reference;
if let Some(s) = snap {
factor = snap_round(factor, s.scale);
}
// Floor at a small positive value so a runaway drag past the origin
// can't flip scale negative (which crashes inverse-transform math).
factor = factor.max(0.001);
let mut out = drag.start_transform;
out.scale = drag.start_transform.scale * factor;
out
}
#[cfg(test)]
mod tests {
use super::*;
use oxide_engine::math::Vec3;
use std::f32::consts::FRAC_PI_2;
fn id_transform_at(p: Vec3) -> Transform {
Transform {
translation: p,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
// --- Hit testing ----------------------------------------------------
#[test]
fn hit_test_picks_translate_axis_under_cursor() {
// Camera looking straight down -Z at origin.
let ray = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let hit = hit_test(
&ray,
&id_transform_at(Vec3::ZERO),
GizmoMode::Translate,
1.0,
0.1,
);
assert_eq!(hit, Some(GizmoHandle::TranslateAxis(Axis3::X)));
}
#[test]
fn hit_test_picks_translate_plane_inside_quad() {
let ray = Ray::new(Vec3::new(0.5, 0.5, 5.0), Vec3::new(0.0, 0.0, -1.0));
let hit = hit_test(
&ray,
&id_transform_at(Vec3::ZERO),
GizmoMode::Translate,
1.0,
0.1,
);
assert_eq!(hit, Some(GizmoHandle::TranslatePlane(PlaneAxis::XY)));
}
#[test]
fn hit_test_picks_rotate_circle_on_radius() {
// Camera looking down +X, so the rotate-X circle is in YZ plane.
// Aim at a point on that circle of radius 1.
let ray = Ray::new(Vec3::new(5.0, 1.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
let hit = hit_test(
&ray,
&id_transform_at(Vec3::ZERO),
GizmoMode::Rotate,
1.0,
0.1,
);
assert_eq!(hit, Some(GizmoHandle::RotateAxis(Axis3::X)));
}
#[test]
fn hit_test_misses_when_ray_far_from_handles() {
let ray = Ray::new(Vec3::new(50.0, 50.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let hit = hit_test(
&ray,
&id_transform_at(Vec3::ZERO),
GizmoMode::Translate,
1.0,
0.1,
);
assert!(hit.is_none());
}
#[test]
fn hit_test_picks_scale_uniform_at_center() {
let ray = Ray::new(Vec3::ZERO + Vec3::Z * 5.0, -Vec3::Z);
let hit = hit_test(
&ray,
&id_transform_at(Vec3::ZERO),
GizmoMode::Scale,
1.0,
0.1,
);
assert_eq!(hit, Some(GizmoHandle::ScaleUniform));
}
// --- Translate drag -------------------------------------------------
#[test]
fn translate_axis_drag_moves_along_axis_only() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::TranslateAxis(Axis3::X),
start_transform: start,
start_anchor: Vec3::ZERO,
reference: 1.0,
};
// Ray that closest-approaches X at x = 3.
let cur = Ray::new(Vec3::new(3.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let out = apply_drag(&drag, &cur, None);
assert!((out.translation.x - 3.0).abs() < 1e-4);
assert!(out.translation.y.abs() < 1e-4);
assert!(out.translation.z.abs() < 1e-4);
}
#[test]
fn translate_axis_snap_rounds_to_distance_step() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::TranslateAxis(Axis3::X),
start_transform: start,
start_anchor: Vec3::ZERO,
reference: 1.0,
};
let cur = Ray::new(Vec3::new(0.74, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let snap = SnapSettings {
distance: 0.25,
..SnapSettings::default()
};
let out = apply_drag(&drag, &cur, Some(&snap));
// 0.74 rounds to 0.75.
assert!((out.translation.x - 0.75).abs() < 1e-4);
}
#[test]
fn translate_plane_drag_moves_in_both_axes() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::TranslatePlane(PlaneAxis::XY),
start_transform: start,
start_anchor: Vec3::ZERO,
reference: 1.0,
};
let cur = Ray::new(Vec3::new(2.0, 3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let out = apply_drag(&drag, &cur, None);
assert!((out.translation.x - 2.0).abs() < 1e-4);
assert!((out.translation.y - 3.0).abs() < 1e-4);
assert!(out.translation.z.abs() < 1e-4);
}
// --- Rotate drag ----------------------------------------------------
#[test]
fn rotate_around_x_axis_produces_quarter_turn() {
let start = id_transform_at(Vec3::ZERO);
// Click at the +Y point on the YZ circle.
let drag = GizmoDrag {
handle: GizmoHandle::RotateAxis(Axis3::X),
start_transform: start,
start_anchor: Vec3::new(0.0, 1.0, 0.0),
reference: 1.0,
};
// Drag to the +Z point — 90° around +X (right-hand rule from +Y → +Z).
let cur = Ray::new(Vec3::new(5.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, 0.0));
let out = apply_drag(&drag, &cur, None);
// Apply the rotation to Y and confirm it lands on Z.
let rotated = out.rotation * Vec3::Y;
assert!((rotated - Vec3::Z).length() < 1e-4);
}
#[test]
fn rotate_snap_rounds_to_angle_step() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::RotateAxis(Axis3::X),
start_transform: start,
start_anchor: Vec3::new(0.0, 1.0, 0.0),
reference: 1.0,
};
// Drag to ~89°: should snap to 90° with a 15° step.
let cur = Ray::new(Vec3::new(5.0, 0.0175, 0.9998), Vec3::new(-1.0, 0.0, 0.0));
let snap = SnapSettings {
angle_deg: 15.0,
..SnapSettings::default()
};
let out = apply_drag(&drag, &cur, Some(&snap));
let rotated = out.rotation * Vec3::Y;
// A 90° rotation around X maps Y → Z exactly.
assert!(
(rotated - Vec3::Z).length() < 1e-3,
"expected snap to 90°, got {rotated:?}"
);
}
#[test]
fn rotate_no_movement_returns_start_transform() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::RotateAxis(Axis3::Y),
start_transform: start,
start_anchor: Vec3::new(1.0, 0.0, 0.0),
reference: 1.0,
};
// Ray pointing back at the anchor (no rotation).
let cur = Ray::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(-1.0, 0.0, 0.0));
let out = apply_drag(&drag, &cur, None);
// Quaternion should be ~identity.
let rotated = out.rotation * Vec3::Z;
assert!((rotated - Vec3::Z).length() < 1e-3);
}
// --- Scale drag -----------------------------------------------------
#[test]
fn scale_axis_doubles_when_pointer_moves_to_2x_anchor() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::ScaleAxis(Axis3::X),
start_transform: start,
start_anchor: Vec3::new(1.0, 0.0, 0.0),
reference: 1.0,
};
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let out = apply_drag(&drag, &cur, None);
assert!((out.scale.x - 2.0).abs() < 1e-4);
assert!((out.scale.y - 1.0).abs() < 1e-4);
assert!((out.scale.z - 1.0).abs() < 1e-4);
}
#[test]
fn scale_axis_floors_at_small_positive_value() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::ScaleAxis(Axis3::Y),
start_transform: start,
start_anchor: Vec3::new(0.0, 1.0, 0.0),
reference: 1.0,
};
// Drag well past the origin — would naively give factor = -3.
let cur = Ray::new(Vec3::new(0.0, -3.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let out = apply_drag(&drag, &cur, None);
// Clamped to a small positive floor — never negative.
assert!(out.scale.y > 0.0);
assert!(out.scale.y < 0.01);
}
#[test]
fn scale_uniform_doubles_along_every_axis() {
let mut start = id_transform_at(Vec3::ZERO);
start.scale = Vec3::new(1.0, 2.0, 3.0);
// With reference = 1.0, dragging the perpendicular distance from
// 1.0 (the start anchor) to 2.0 grows the factor by exactly 1.0.
let drag = GizmoDrag {
handle: GizmoHandle::ScaleUniform,
start_transform: start,
start_anchor: Vec3::new(1.0, 0.0, 0.0),
reference: 1.0,
};
let cur = Ray::new(Vec3::new(2.0, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let out = apply_drag(&drag, &cur, None);
assert!((out.scale.x - 2.0).abs() < 1e-4);
assert!((out.scale.y - 4.0).abs() < 1e-4);
assert!((out.scale.z - 6.0).abs() < 1e-4);
}
#[test]
fn scale_uniform_is_not_supersensitive_when_click_lands_near_center() {
// The old `now_dist / start_dist` formula blew up when a click
// landed near the gizmo center (start_dist ≈ 0). The new formula
// is additive in the perpendicular delta, so a tiny start_dist
// does not amplify the factor.
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::ScaleUniform,
start_transform: start,
// Click landed near the center (perp distance 0.05).
start_anchor: Vec3::new(0.05, 0.0, 0.0),
reference: 1.0,
};
// Drag the pointer to a new perp distance of 0.5 (so delta = 0.45).
let cur = Ray::new(Vec3::new(0.5, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let out = apply_drag(&drag, &cur, None);
// factor = 1.0 + 0.45 / 1.0 = 1.45 — gentle. The old formula would
// give 0.5 / 0.05 = 10.0, which is what the maintainer reported.
assert!(
(out.scale.x - 1.45).abs() < 1e-3,
"expected gentle factor 1.45, got scale {:?}",
out.scale
);
}
#[test]
fn scale_uniform_snap_rounds_factor() {
// Reported by the maintainer: uniform-scale snap did nothing. The
// old formula's runaway factor swamped the snap step; the new
// additive formula puts the factor in a sane range so snap_round
// can hit a sensible step.
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::ScaleUniform,
start_transform: start,
start_anchor: Vec3::new(0.5, 0.0, 0.0),
reference: 1.0,
};
// Pointer at perp distance ~1.32 → factor 1 + (1.32 - 0.5) = 1.82
// → snaps to 1.8 (step 0.1).
let cur = Ray::new(Vec3::new(1.32, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let snap = SnapSettings {
scale: 0.1,
..SnapSettings::default()
};
let out = apply_drag(&drag, &cur, Some(&snap));
assert!(
(out.scale.x - 1.8).abs() < 1e-3,
"uniform-scale snap should round 1.82 to 1.8, got {:?}",
out.scale
);
}
#[test]
fn scale_snap_rounds_factor_to_step() {
let start = id_transform_at(Vec3::ZERO);
let drag = GizmoDrag {
handle: GizmoHandle::ScaleAxis(Axis3::X),
start_transform: start,
start_anchor: Vec3::new(1.0, 0.0, 0.0),
reference: 1.0,
};
// Pointer at 1.83 → factor 1.83 → snaps to 1.8 (step 0.1).
let cur = Ray::new(Vec3::new(1.83, 0.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let snap = SnapSettings {
scale: 0.1,
..SnapSettings::default()
};
let out = apply_drag(&drag, &cur, Some(&snap));
assert!((out.scale.x - 1.8).abs() < 1e-4);
}
// --- Helpers --------------------------------------------------------
#[test]
fn closest_point_on_axis_recovers_perpendicular_drop() {
let ray = Ray::new(Vec3::new(3.0, 4.0, 5.0), Vec3::new(0.0, 0.0, -1.0));
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
// Drop a perpendicular onto the X axis — should land at (3, 0, 0).
assert!((pt - Vec3::new(3.0, 0.0, 0.0)).length() < 1e-4);
}
#[test]
fn closest_point_on_axis_handles_parallel_ray() {
// Ray along X overlaps the X axis exactly — returns the axis origin.
let ray = Ray::new(Vec3::new(0.0, 2.0, 0.0), Vec3::X);
let pt = closest_point_on_line(Vec3::ZERO, Vec3::X, &ray);
assert_eq!(pt, Vec3::ZERO);
}
#[test]
fn snap_round_to_step() {
assert_eq!(snap_round(0.74, 0.25), 0.75);
assert_eq!(snap_round(0.12, 0.25), 0.0);
assert_eq!(snap_round(-0.74, 0.25), -0.75);
// Zero / negative step disables snapping.
assert_eq!(snap_round(0.74, 0.0), 0.74);
assert_eq!(snap_round(0.74, -0.5), 0.74);
}
#[test]
fn gizmo_handle_maps_to_mode() {
assert_eq!(
GizmoHandle::TranslateAxis(Axis3::X).mode(),
GizmoMode::Translate
);
assert_eq!(
GizmoHandle::TranslatePlane(PlaneAxis::XY).mode(),
GizmoMode::Translate
);
assert_eq!(GizmoHandle::RotateAxis(Axis3::Z).mode(), GizmoMode::Rotate);
assert_eq!(GizmoHandle::ScaleAxis(Axis3::Y).mode(), GizmoMode::Scale);
assert_eq!(GizmoHandle::ScaleUniform.mode(), GizmoMode::Scale);
}
#[test]
fn axis3_unit_and_index_align() {
for axis in Axis3::ALL {
let unit = axis.unit();
let idx = axis.index();
let mut expected = [0.0; 3];
expected[idx] = 1.0;
assert_eq!(unit.to_array(), expected);
}
}
#[test]
fn plane_axis_normal_is_orthogonal_to_its_axes() {
for plane in PlaneAxis::ALL {
let n = plane.normal();
let (a, b) = plane.axes();
assert!(n.dot(a).abs() < 1e-6);
assert!(n.dot(b).abs() < 1e-6);
// The two axes within the plane are also orthogonal.
assert!(a.dot(b).abs() < 1e-6);
}
}
// The rotation around X used FRAC_PI_2 indirectly via 90° axis drag.
// This second test just confirms a clean 90° around Y matches the
// expected matrix-applied direction.
#[test]
fn rotate_y_90_maps_x_to_minus_z() {
// Manually construct a 90° Y rotation and confirm orientation.
let q = Quat::from_axis_angle(Vec3::Y, FRAC_PI_2);
let v = q * Vec3::X;
assert!((v - Vec3::new(0.0, 0.0, -1.0)).length() < 1e-4);
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Oxide Editor — framework library.
//!
//! The editor is built as a library of reusable, testable framework pieces plus
//! a thin binary (`src/main.rs`) that wires them into a window. Stage 6 grows
//! this library into the editor *framework*: an undo/redo command stack, a
//! project system, a settings/preferences framework, a module→editor extension
//! API, and the docking shell.
//!
//! Keeping the framework here (rather than in the binary) means each piece is
//! unit-tested in isolation, and the binary stays a small amount of glue.
#![deny(warnings)]
pub mod assets;
pub mod bindings;
pub mod command;
pub mod commands;
pub mod console;
pub mod extension;
pub mod gizmo;
pub mod play;
pub mod preferences;
pub mod pty;
pub mod shell;
pub mod state;
pub mod terminal;
+720
View File
@@ -0,0 +1,720 @@
//! Oxide Editor — entry point.
//!
//! The in-engine editor is built as a first-class part of the Oxide project.
//! It grows alongside the engine, gaining new panels and tools at each stage.
//!
//! Stage 6 wires the framework pieces (command stack, project system, settings
//! framework, extension API, file watcher) into a docking
//! [`Shell`](oxide_editor::shell::Shell). The shell hosts the hierarchy,
//! inspector, viewport, project browser, and console as resizable dockable
//! panels under a top menu bar + bottom status bar, with a Preferences window
//! driven by `Settings`. This binary is glue: window/event loop, the 3D
//! viewport renderer + camera, and the egui paint pump.
#![deny(warnings)]
mod egui_layer;
mod viewport;
use egui_layer::EguiLayer;
use oxide_editor::bindings::action;
use oxide_editor::commands::SetTransformCmd;
use oxide_editor::gizmo::{self, GizmoDrag, GizmoMode};
use oxide_editor::play::{self, Tick};
use oxide_editor::{preferences, shell::Shell};
use oxide_engine::app::{App, DefaultModules};
use oxide_engine::prelude::*;
use oxide_engine::window::event::{
ElementState, KeyCode, ModifiersState, MouseButton, MouseScrollDelta, PhysicalKey, WindowEvent,
};
use oxide_engine::window::RenderCtx;
use viewport::{CameraMode, Viewport};
/// World-space length of the gizmo arrows / handles, scaled per-frame by
/// camera distance so the gizmo stays roughly the same pixel size at any
/// zoom level. The pure-logic gizmo math is agnostic to this scale — it
/// just takes whatever value the host passes.
const GIZMO_SCREEN_HEIGHT_FRACTION: f32 = 0.13;
/// Pixel-distance threshold for a gizmo handle to count as "hit" by a
/// click. Converted to world units per-frame using the camera distance so
/// the same screen tolerance applies at any zoom.
const GIZMO_HIT_PIXEL_TOLERANCE: f32 = 10.0;
/// Background color of the 3D viewport (dark neutral gray).
const VIEWPORT_CLEAR: Color = Color::rgb(0.08, 0.08, 0.10);
struct EditorApp {
shell: Shell,
egui_layer: Option<EguiLayer>,
viewport: Option<Viewport>,
/// The play-mode runtime (Stage 8.7). `Some` exactly while the editor is
/// playing or paused: built when Play starts (engine `App` + default
/// modules), ticked each frame, and dropped when Stop returns to editing.
/// The editor's scene is swapped into it for each tick and back out again,
/// so `shell.state.scene` stays the single source of truth between frames.
play_app: Option<App>,
modifiers: ModifiersState,
/// Last cursor position (physical px), for computing drag deltas.
last_cursor: Option<(f32, f32)>,
/// Left mouse held over the viewport — orbit (or pick on release).
orbiting: bool,
/// Right/middle mouse held over the viewport — pan (orbit mode) or
/// look around (flythrough mode); the camera-mode dispatch happens in
/// the cursor-moved handler.
panning: bool,
/// Accumulated cursor travel since the left press, to tell a click (select)
/// from a drag (orbit).
left_drag_dist: f32,
}
impl EditorApp {
fn new() -> Self {
let mut shell = Shell::new();
// Defaults are registered by EditorState::new; layer any saved user
// remap from `~/.config/oxide/editor.ron` on top before the first
// input poll runs.
if let Some(saved) = preferences::load() {
shell.state.settings.import(&saved);
shell.state.apply_action_overrides_from_settings();
}
Self {
shell,
egui_layer: None,
viewport: None,
play_app: None,
modifiers: ModifiersState::empty(),
last_cursor: None,
orbiting: false,
panning: false,
left_drag_dist: 0.0,
}
}
/// Constructs the world-space cursor ray using the active viewport
/// camera + the Viewport tab's sub-rect. Returns `None` if the viewport
/// hasn't been initialized yet or the last cursor is unknown.
fn cursor_ray(&self, size: (u32, u32)) -> Option<oxide_engine::math::Ray> {
let cursor = self.last_cursor?;
let vp = self.viewport.as_ref()?;
Some(vp.ray_from_cursor(cursor, size, self.shell.viewport_rect()))
}
/// World-space gizmo size that the maths uses for both rendering and
/// hit testing. Scaled by the camera's distance to the selection so the
/// gizmo keeps a stable pixel size at any zoom level.
fn gizmo_world_size(&self, target: oxide_engine::math::Vec3) -> f32 {
let Some(vp) = self.viewport.as_ref() else {
return 1.0;
};
let eye = match vp.mode {
CameraMode::Orbit => vp.orbit.view_transform().translation,
CameraMode::Flythrough => vp.flythrough.position,
};
let d = (target - eye).length().max(0.1);
d * GIZMO_SCREEN_HEIGHT_FRACTION
}
/// Tries to start a gizmo drag at the cursor. Returns `true` if a
/// handle was hit (so the caller can skip orbit/look for this click).
fn try_begin_gizmo_drag(&mut self, size: (u32, u32)) -> bool {
let Some(selected) = self.shell.state.selected else {
return false;
};
let Some(transform) = self.shell.state.scene.world_transform(selected) else {
return false;
};
let Some(ray) = self.cursor_ray(size) else {
return false;
};
let world_size = self.gizmo_world_size(transform.translation);
// Hit tolerance is a fixed pixel size; convert to world units the
// same way the gizmo size is scaled (the math is approximate but
// good enough for the few-pixel target zone).
let tolerance = world_size * (GIZMO_HIT_PIXEL_TOLERANCE / 100.0);
let mode = self.shell.state.gizmo.mode;
let Some(handle) = gizmo::hit_test(&ray, &transform, mode, world_size, tolerance) else {
return false;
};
// Compute the drag's start anchor — the point on the engaged
// handle the click corresponds to. Mirrors what `apply_drag`
// expects on subsequent frames.
let start_anchor = match handle {
gizmo::GizmoHandle::TranslateAxis(axis) | gizmo::GizmoHandle::ScaleAxis(axis) => {
gizmo::closest_point_on_line(transform.translation, axis.unit(), &ray)
}
gizmo::GizmoHandle::TranslatePlane(plane) => {
let p = oxide_engine::math::Plane::from_point_normal(
transform.translation,
plane.normal(),
);
p.ray_intersection(&ray)
.map(|t| ray.at(t))
.unwrap_or(transform.translation)
}
gizmo::GizmoHandle::RotateAxis(axis) => {
let p = oxide_engine::math::Plane::from_point_normal(
transform.translation,
axis.unit(),
);
p.ray_intersection(&ray)
.map(|t| ray.at(t))
.unwrap_or(transform.translation)
}
gizmo::GizmoHandle::ScaleUniform => ray.closest_point(transform.translation),
};
// The uniform-scale handle uses `reference` as the world distance
// corresponding to one factor of change — match it to the gizmo
// size so dragging by ~one arm's length doubles the scale.
let reference = if matches!(handle, gizmo::GizmoHandle::ScaleUniform) {
world_size
} else {
1.0
};
self.shell.state.gizmo.drag = Some(GizmoDrag {
handle,
start_transform: transform,
start_anchor,
reference,
});
true
}
/// Updates the in-progress drag against the current cursor position,
/// applying the new transform directly to the selected entity. The
/// command stack is only touched on release; intermediate frames just
/// mutate the scene so the gizmo follows the pointer fluidly.
fn advance_gizmo_drag(&mut self, size: (u32, u32), cursor: (f32, f32)) {
let Some(drag) = self.shell.state.gizmo.drag else {
return;
};
let Some(selected) = self.shell.state.selected else {
return;
};
let Some(vp) = self.viewport.as_ref() else {
return;
};
let ray = vp.ray_from_cursor(cursor, size, self.shell.viewport_rect());
// Ctrl-held → snap; the snap settings live on the editor state so
// a future preferences page can tune the steps.
let snap = self
.modifiers
.control_key()
.then_some(&self.shell.state.gizmo.snap);
let next = gizmo::apply_drag(&drag, &ray, snap);
// Drag math operates in world space (start_transform was the
// entity's *world* transform); for an entity with parents the
// computed `next` lives in world space too, so writing it as the
// local transform is only exact when the entity has no parent.
// Hierarchy-aware gizmo math is a refinement for a later piece.
self.shell.state.scene.set_local_transform(selected, next);
}
/// Commits the in-progress drag (if any) by pushing a `SetTransformCmd`
/// onto the command stack and clearing the drag — making the whole
/// drag one undo entry.
fn end_gizmo_drag(&mut self) {
let Some(drag) = self.shell.state.gizmo.drag.take() else {
return;
};
let Some(selected) = self.shell.state.selected else {
return;
};
let Some(after) = self.shell.state.scene.local_transform(selected) else {
return;
};
// Skip the command when nothing actually changed (the user clicked
// a handle but didn't drag).
let before = drag.start_transform;
if before == after {
return;
}
self.shell.push_command(SetTransformCmd {
entity: selected,
before,
after,
});
}
/// Builds the per-frame [`ViewportOverlay`] the Shell's Viewport tab paints
/// every world-space overlay with (transform gizmo, collider wireframes, the
/// raycast probe). `None` only when the viewport isn't initialized yet — the
/// `view_proj` is always available, so colliders/probe show without a
/// selection; `gizmo_size` falls back to a unit when nothing is selected
/// (the gizmo itself isn't painted then, so the value is unused there).
fn build_gizmo_overlay(
&self,
size: (u32, u32),
rect: Option<oxide_engine::math::Rect>,
) -> Option<oxide_editor::shell::ViewportOverlay> {
let vp = self.viewport.as_ref()?;
let gizmo_size = self
.shell
.state
.selected
.and_then(|e| self.shell.state.scene.world_transform(e))
.map(|t| self.gizmo_world_size(t.translation))
.unwrap_or(1.0);
Some(oxide_editor::shell::ViewportOverlay {
view_proj: vp.view_projection_for(rect, size),
gizmo_size,
})
}
/// **Freezes** a raycast probe: casts the editor camera→cursor ray against
/// the *edited* scene's colliders right now and stores the result on the
/// Shell so the Viewport tab keeps drawing it in world space (Stage 9 piece
/// 8c). Because the ray is frozen into the world, 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. Builds a transient [`PhysicsWorld`] from
/// the scene via [`sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene)
/// so the probe reflects unsaved edits without requiring Play. No-op if the
/// cursor or viewport is unavailable or the ray is degenerate.
fn cast_probe_ray(&mut self, size: (u32, u32)) {
use oxide_editor::shell::{RaycastProbeHit, RaycastProbeViz};
let rect = self.shell.viewport_rect();
let Some(cursor) = self.last_cursor else {
return;
};
let Some(vp) = self.viewport.as_ref() else {
return;
};
let ray = vp.ray_from_cursor(cursor, size, rect);
if ray.direction == oxide_engine::math::Vec3::ZERO {
return;
}
const PROBE_DISTANCE: f32 = 1000.0;
let mut world = oxide_physics::PhysicsWorld::new();
world.sync_to_scene(&self.shell.state.scene);
let hit = world.raycast(
ray.origin,
ray.direction,
PROBE_DISTANCE,
oxide_engine::layer::LayerMask::ALL,
);
// Surface a one-line result so the cast gives feedback even before the
// user orbits to look at the frozen ray.
match hit {
Some(h) => {
let name = self
.shell
.state
.scene
.name(h.entity)
.unwrap_or_else(|| "<entity>".to_string());
self.shell
.set_status_hint(format!("Raycast probe: hit {name}"));
}
None => self.shell.set_status_hint("Raycast probe: miss"),
}
self.shell.set_raycast_probe_viz(Some(RaycastProbeViz {
origin: ray.origin,
end: hit
.map(|h| h.point)
.unwrap_or_else(|| ray.at(PROBE_DISTANCE)),
hit: hit.map(|h| RaycastProbeHit {
point: h.point,
normal: h.normal,
}),
}));
}
/// Writes the editor's preferences file to disk when the shell flagged
/// a binding edit since the last call. Logs (but does not panic on) I/O
/// errors — losing one save is recoverable; crashing the editor is not.
fn save_preferences_if_dirty(&mut self) {
if !self.shell.take_bindings_dirty() {
return;
}
let snapshot = self.shell.state.settings.export();
if let Err(err) = preferences::save(&snapshot) {
log::warn!("failed to save editor preferences: {err}");
}
}
/// Drives the play-mode runtime (Stage 8.7). Reconciles the play `App`'s
/// existence with the editor's [`PlayState`] (build it on Play, drop it on
/// Stop), then advances the simulation as far as
/// [`play::tick_for`](oxide_editor::play::tick_for) decides — swapping the
/// editor scene into the `App` for the tick and back out so the rest of the
/// editor keeps seeing `shell.state.scene`.
///
/// Called unconditionally each frame, before the cursor-gated editor input,
/// so play continues regardless of where the pointer is.
fn drive_play(&mut self, dt: f32) {
let in_play = self.shell.state.is_in_play();
// Build the runtime when play starts; tear it down when it stops. The
// engine `App` carries the default modules plus physics (Stage 9) and
// scripting (Stage 10); the project's own modules register here too in a
// later stage.
if in_play && self.play_app.is_none() {
let mut app = App::new();
// Share the editor's asset server so the play app resolves the same
// assets *and* the file watcher's in-place reloads (which target the
// editor server) reach a **playing** scene — live-reloading a script
// while the scene runs.
app.assets = self.shell.state.assets.clone();
app.add_modules(DefaultModules);
app.add_module(oxide_physics::PhysicsModule);
app.add_module(oxide_script::ScriptModule);
// Scripts resolve their `AssetRef<ScriptAsset>` through the project's
// asset database; hand the play app a snapshot so uids map to files.
if let Some(db) = &self.shell.state.asset_db {
app.insert_resource(db.clone());
}
self.play_app = Some(app);
} else if !in_play && self.play_app.is_some() {
self.play_app = None;
}
let tick = play::tick_for(self.shell.state.play, self.shell.take_step_request());
if matches!(tick, Tick::Idle) {
return;
}
let Some(app) = self.play_app.as_mut() else {
return;
};
// Run the engine schedule against the editor's live scene, then hand it
// back. `swap` is O(1) (two `Scene` moves), so the editor scene is only
// "inside" the App for the duration of the tick.
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
match tick {
Tick::Frame => app.update(dt),
Tick::FixedStep => app.step(),
Tick::Idle => {}
}
std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
}
/// Ray-picks the entity under the cursor and selects it (or clears the
/// selection if the ray misses everything).
fn pick_under_cursor(&mut self, size: (u32, u32)) {
let Some(cursor) = self.last_cursor else {
return;
};
let rect = self.shell.viewport_rect();
let picked = self
.viewport
.as_ref()
.and_then(|vp| vp.pick(&self.shell.state.scene, cursor, size, rect));
self.shell.select(picked);
}
}
impl WindowApp for EditorApp {
fn init(&mut self, ctx: &mut AppCtx<'_>) {
let (w, h) = ctx.size();
let device = ctx.render().gpu().device().clone();
let format = ctx.render().surface_format();
let layer = EguiLayer::new(ctx.window(), &device, format);
self.egui_layer = Some(layer);
self.viewport = Some(Viewport::new(&device, format));
log::info!(
"editor window open ({w}x{h}); docking shell active \
(L-drag: orbit · R-drag: pan · scroll: zoom · F: toggle flythrough · \
Ctrl+Z: undo · Ctrl+Q: quit)"
);
}
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
// Let egui handle the event first (text fields, clicks, scrolling).
// `consumed` is true when the pointer is over an egui widget, but
// the Viewport tab is technically an egui widget too — so egui would
// claim every click in the central area. Override that: if the cursor
// is over the Viewport tab's rect we treat the event as ours, so
// orbit/pan/zoom/pick work inside the dock.
let egui_consumed = self
.egui_layer
.as_mut()
.map(|layer| layer.on_window_event(ctx.window(), event))
.unwrap_or(false);
// A floating panel (egui Window) can overlap the viewport rect; when
// the pointer is over one, the click belongs to egui, not the 3D view —
// otherwise we'd drag the panel and orbit the camera at the same time.
let over_floating = self
.egui_layer
.as_ref()
.map(|layer| layer.pointer_over_floating())
.unwrap_or(false);
let over_viewport = !over_floating
&& self
.last_cursor
.map(|c| self.shell.cursor_over_viewport(c))
.unwrap_or(false);
let consumed = egui_consumed && !over_viewport;
match event {
WindowEvent::ModifiersChanged(modifiers) => {
self.modifiers = modifiers.state();
// If a gizmo drag is in flight, re-apply it with the new
// modifier state so toggling Ctrl mid-drag snaps (or
// unsnaps) the current position immediately — even when
// the mouse hasn't moved since.
if self.shell.state.gizmo.drag.is_some() {
if let Some(cursor) = self.last_cursor {
self.advance_gizmo_drag(ctx.size(), cursor);
}
}
}
WindowEvent::KeyboardInput { event, .. } => {
if event.state != ElementState::Pressed {
return;
}
let ctrl = self.modifiers.control_key();
let shift = self.modifiers.shift_key();
// Engine-global Ctrl+Q is handled here; everything else is
// delegated to the shell so the same shortcut routing is
// exercised by tests.
if ctrl && event.physical_key == PhysicalKey::Code(KeyCode::KeyQ) {
log::info!("Ctrl+Q — exiting editor");
ctx.request_exit();
return;
}
if ctrl {
let ch = match event.physical_key {
PhysicalKey::Code(KeyCode::KeyZ) => Some('z'),
PhysicalKey::Code(KeyCode::KeyY) => Some('y'),
PhysicalKey::Code(KeyCode::KeyS) => Some('s'),
PhysicalKey::Code(KeyCode::Comma) => Some(','),
PhysicalKey::Code(KeyCode::KeyP) => Some('p'),
PhysicalKey::Code(KeyCode::Period) => Some('.'),
_ => None,
};
if let Some(ch) = ch {
self.shell.try_consume_shortcut(true, shift, Some(ch));
}
}
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = *state == ElementState::Pressed;
match button {
MouseButton::Left => {
if pressed {
// Try a gizmo handle first — if the click hit
// one, we start a drag instead of orbiting.
let on_gizmo = !consumed && self.try_begin_gizmo_drag(ctx.size());
self.orbiting = !consumed && !on_gizmo;
self.left_drag_dist = 0.0;
} else {
// Release: commit the gizmo drag if any (one
// SetTransformCmd per drag = one undo entry).
if self.shell.state.gizmo.drag.is_some() {
self.end_gizmo_drag();
} else if self.orbiting && self.left_drag_dist < 4.0 {
// Click without drag → pick, and (if the raycast
// probe is on) freeze a debug ray into the world
// so it can be inspected by orbiting the camera.
self.pick_under_cursor(ctx.size());
if self.shell.raycast_probe_enabled() {
self.cast_probe_ray(ctx.size());
}
}
self.orbiting = false;
}
}
MouseButton::Right | MouseButton::Middle => self.panning = pressed && !consumed,
_ => {}
}
}
WindowEvent::CursorMoved { position, .. } => {
let pos = (position.x as f32, position.y as f32);
if let Some((lx, ly)) = self.last_cursor {
let (dx, dy) = (pos.0 - lx, pos.1 - ly);
if self.orbiting {
self.left_drag_dist += dx.abs() + dy.abs();
}
// If a gizmo drag is in flight, route the move into the
// gizmo math and skip the camera controls entirely.
if self.shell.state.gizmo.drag.is_some() {
self.advance_gizmo_drag(ctx.size(), pos);
} else if let Some(vp) = self.viewport.as_mut() {
match vp.mode {
CameraMode::Orbit => {
if self.orbiting {
vp.orbit.orbit(dx, dy);
} else if self.panning {
vp.orbit.pan(dx, dy);
}
}
CameraMode::Flythrough => {
// In flythrough the existing right-drag gesture
// becomes mouse-look; left-drag is a no-op for
// the camera (click-without-drag still picks).
if self.panning {
vp.flythrough.look(dx, dy);
}
}
}
}
}
self.last_cursor = Some(pos);
}
WindowEvent::MouseWheel { delta, .. } if !consumed => {
let amount = match delta {
MouseScrollDelta::LineDelta(_, y) => *y,
MouseScrollDelta::PixelDelta(p) => p.y as f32 / 40.0,
};
if let Some(vp) = self.viewport.as_mut() {
// Scroll has different roles per mode: zoom-in/out for the
// orbit subject, faster/slower travel for the flythrough.
match vp.mode {
CameraMode::Orbit => vp.orbit.zoom(amount),
CameraMode::Flythrough => vp.flythrough.adjust_move_speed(amount),
}
}
}
_ => {}
}
}
fn update(&mut self, ctx: &mut AppCtx<'_>) {
// Drain the file watcher into AssetServer::reload_path and age out
// the status bar's last hint.
self.shell.frame_tick();
// Propagate File → Quit (the shell can't reach the runner directly).
if self.shell.take_quit_request() {
ctx.request_exit();
}
// Advance the play-mode simulation (if any) every frame, before the
// cursor-gated editor input below — play must not depend on the pointer
// being over the viewport.
self.drive_play(ctx.dt);
// Bindings preferences page — when a capture is in progress, consume
// the next pressed key/button into the targeted slot. Runs before
// any other input poll so the captured press doesn't double-fire
// a normal action.
let input = ctx.input();
if self.shell.capture_active() {
self.shell.try_complete_capture(input);
// Flush to disk if the capture committed a binding.
self.save_preferences_if_dirty();
return;
}
// Editor input — polled per-frame from the Stage-7 InputState. Only
// fires when the cursor is over the Viewport tab so the same keys do
// not steal focus from a search box or text field elsewhere.
let over_floating = self
.egui_layer
.as_ref()
.map(|layer| layer.pointer_over_floating())
.unwrap_or(false);
let cursor_over_vp = !over_floating
&& self
.last_cursor
.map(|c| self.shell.cursor_over_viewport(c))
.unwrap_or(false);
if !cursor_over_vp {
// Even off the viewport, a "Restore defaults" click from the
// preferences UI marks the bindings dirty — flush here.
self.save_preferences_if_dirty();
return;
}
let actions = &self.shell.state.actions;
if actions.action_pressed(action::TOGGLE_FLYTHROUGH, input) {
if let Some(vp) = self.viewport.as_mut() {
let new_mode = vp.toggle_camera_mode();
let label = match new_mode {
CameraMode::Orbit => "Camera: Orbit (L-drag orbit · R-drag pan · scroll zoom)",
CameraMode::Flythrough => {
"Camera: Flythrough (WASD/QE move · Shift sprint · R-drag look · scroll speed)"
}
};
log::info!("{label}");
self.shell.set_status_hint(label);
}
}
if let Some(vp) = self.viewport.as_mut() {
if vp.mode == CameraMode::Flythrough {
let actions = &self.shell.state.actions;
let right = actions.axis(action::MOVE_RIGHT, input);
let forward = actions.axis(action::MOVE_FORWARD, input);
let up = actions.axis(action::MOVE_UP, input);
// The camera's translate_local takes (right, up, -forward),
// i.e. -Z is camera-forward, mirroring the OrbitCamera's
// looking_at convention.
let local = oxide_engine::math::Vec3::new(right, up, -forward);
let sprint = actions.action_held(action::SPRINT, input);
vp.flythrough.translate_local(local, ctx.dt, sprint);
}
}
// Gizmo tool hotkeys (W/E/R by default). Share keys with flythrough
// movement, so they only fire in orbit mode — in flythrough WASD
// moves the camera.
let orbit_mode = matches!(
self.viewport.as_ref().map(|v| v.mode),
Some(CameraMode::Orbit)
);
if orbit_mode {
let actions = &self.shell.state.actions;
let new_mode = if actions.action_pressed(action::GIZMO_TRANSLATE, input) {
Some(GizmoMode::Translate)
} else if actions.action_pressed(action::GIZMO_ROTATE, input) {
Some(GizmoMode::Rotate)
} else if actions.action_pressed(action::GIZMO_SCALE, input) {
Some(GizmoMode::Scale)
} else {
None
};
if let Some(mode) = new_mode {
self.shell.state.gizmo.mode = mode;
log::info!("Gizmo tool: {}", mode.label());
self.shell
.set_status_hint(format!("Gizmo: {}", mode.label()));
}
}
self.save_preferences_if_dirty();
}
fn render(&mut self, ctx: &RenderCtx<'_>) {
// Draw the 3D scene first; egui then composites its panels on top
// (both record with `LoadOp::Load` over the engine's clear). Taken out
// and back so the immutable scene borrow doesn't clash with `&mut self`.
let rect = self.shell.viewport_rect();
if let Some(mut vp) = self.viewport.take() {
vp.render(&self.shell.state.scene, ctx, rect);
self.viewport = Some(vp);
}
// Hand the Shell the data its Viewport tab needs to paint the gizmo
// overlay using the same projection the scene was drawn with.
self.shell
.set_viewport_overlay(self.build_gizmo_overlay(ctx.size, rect));
let Some(mut layer) = self.egui_layer.take() else {
return;
};
let shell = &mut self.shell;
layer.paint(
ctx.window,
ctx.gpu.device(),
ctx.gpu.queue(),
ctx.view,
ctx.size,
|ui| shell.build(ui),
);
self.egui_layer = Some(layer);
}
}
fn main() -> anyhow::Result<()> {
// Install the capturing logger so script output/errors also reach the
// editor Console panel (still prints to the terminal, honours RUST_LOG).
oxide_editor::console::init();
log::info!("Oxide Editor starting…");
let config = WindowConfig {
title: "Oxide Editor".to_string(),
clear_color: VIEWPORT_CLEAR,
..Default::default()
};
run(config, EditorApp::new())
}
+69
View File
@@ -0,0 +1,69 @@
//! The play-mode tick decision (Stage 8.7).
//!
//! The host runner ([`oxide_editor::main`](crate)) owns the play [`App`] and the
//! window loop; this module isolates the one piece of that loop worth testing on
//! its own: **how far to advance the simulation this frame** given the current
//! [`PlayState`] and whether a single **Step** was requested.
//!
//! Keeping it a pure function pins the play-mode contract in a unit test instead
//! of burying it in the (un-testable) GUI runner:
//!
//! - [`Playing`](PlayState::Playing) → advance one real frame ([`Tick::Frame`]).
//! - [`Paused`](PlayState::Paused) + Step → advance exactly one fixed tick
//! ([`Tick::FixedStep`]); a stray Step while *Playing* is ignored (the frame
//! already advances).
//! - [`Editing`](PlayState::Editing), or Paused with no Step → do nothing
//! ([`Tick::Idle`]).
//!
//! [`App`]: oxide_engine::app::App
use crate::state::PlayState;
/// How the host runner should advance the play [`App`](oxide_engine::app::App)
/// this frame. The runner maps each variant onto an engine call: `Frame` →
/// [`App::update`](oxide_engine::app::App::update), `FixedStep` →
/// [`App::step`](oxide_engine::app::App::step), `Idle` → no call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tick {
/// Do not advance the simulation (editing, or paused with no step queued).
Idle,
/// Advance one normal frame by the real delta (playing).
Frame,
/// Advance exactly one fixed timestep (a single step while paused).
FixedStep,
}
/// Decides how to advance the simulation this frame. `step_requested` is whether
/// the user asked for a single **Step** since the last frame; it is honoured
/// only while [`Paused`](PlayState::Paused). See the [module docs](self).
pub fn tick_for(play: PlayState, step_requested: bool) -> Tick {
match play {
PlayState::Playing => Tick::Frame,
PlayState::Paused if step_requested => Tick::FixedStep,
PlayState::Paused | PlayState::Editing => Tick::Idle,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn playing_advances_a_frame_regardless_of_step() {
assert_eq!(tick_for(PlayState::Playing, false), Tick::Frame);
// A stray step while playing is ignored — the frame already advances.
assert_eq!(tick_for(PlayState::Playing, true), Tick::Frame);
}
#[test]
fn paused_steps_only_when_requested() {
assert_eq!(tick_for(PlayState::Paused, false), Tick::Idle);
assert_eq!(tick_for(PlayState::Paused, true), Tick::FixedStep);
}
#[test]
fn editing_never_advances() {
assert_eq!(tick_for(PlayState::Editing, false), Tick::Idle);
assert_eq!(tick_for(PlayState::Editing, true), Tick::Idle);
}
}
+146
View File
@@ -0,0 +1,146 @@
//! Editor-wide preferences persistence on disk.
//!
//! The Stage-6 [`Settings`](oxide_engine::settings::Settings) framework
//! defines *what* is persisted (named sections, each owning a typed value).
//! This module defines *where* — the user-scoped file the editor reads on
//! startup and writes on every change, so a binding remap or theme tweak
//! survives a restart.
//!
//! # Location
//!
//! Linux: `$XDG_CONFIG_HOME/oxide/editor.ron`, falling back to
//! `$HOME/.config/oxide/editor.ron`. The directory is created on demand;
//! the path is the same one a Windows port would use once Stage-16 ships
//! game export (Windows resolution lands then, not here).
//!
//! # Format
//!
//! The file is exactly the RON map [`Settings::export`] produces:
//! `{ "section.name": "(field: value, …)", … }`. Each value is itself a
//! RON-encoded string of that section's typed value. Loading does no
//! schema validation — unknown sections are skipped by `Settings::import`,
//! so removing a section in code never breaks an old file.
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
/// Resolves the absolute path to the editor's preferences file, or `None`
/// if the OS provides no usable home / config directory (a stripped-down
/// container, an unusual launcher environment, …).
pub fn config_path() -> Option<PathBuf> {
resolve_config_path(|k| std::env::var_os(k))
}
/// Resolution rules, factored so tests can inject env state without racing
/// on the real process environment. Returns the first of:
///
/// 1. `$XDG_CONFIG_HOME/oxide/editor.ron`
/// 2. `$HOME/.config/oxide/editor.ron`
/// 3. `None` if neither is set.
fn resolve_config_path(env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let base = env("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("oxide").join("editor.ron"))
}
/// Loads the preferences file, returning the same `BTreeMap` shape
/// [`Settings::import`](oxide_engine::settings::Settings::import) consumes.
///
/// Returns `None` when no file exists yet (a fresh install) or it can't be
/// parsed — both cases are silently treated as "no saved preferences" so
/// the editor falls back to the code-defined defaults. A returned `Some`
/// is the file's contents verbatim; the caller decides what to import.
pub fn load() -> Option<BTreeMap<String, String>> {
let path = config_path()?;
let text = std::fs::read_to_string(&path).ok()?;
ron::from_str(&text).ok()
}
/// Writes `map` to the preferences file, creating the parent directory if
/// necessary. The map is the output of
/// [`Settings::export`](oxide_engine::settings::Settings::export); the
/// editor calls this from the host runner whenever a binding edit or
/// other settings change flips a dirty flag.
pub fn save(map: &BTreeMap<String, String>) -> io::Result<()> {
let path = config_path().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"no $XDG_CONFIG_HOME or $HOME — cannot resolve editor preferences path",
)
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = ron::ser::to_string_pretty(map, ron::ser::PrettyConfig::default())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
std::fs::write(path, text)
}
#[cfg(test)]
mod tests {
use super::*;
/// A throw-away env stub built from a closure — keeps each test free of
/// process-global env mutation, so the suite can run in parallel.
fn env<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |k| {
map.iter()
.find(|(kk, _)| *kk == k)
.map(|(_, v)| OsString::from(*v))
}
}
#[test]
fn config_path_uses_xdg_when_set() {
let p = resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x")])).unwrap();
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
}
#[test]
fn config_path_prefers_xdg_over_home_when_both_set() {
let p =
resolve_config_path(env(&[("XDG_CONFIG_HOME", "/tmp/x"), ("HOME", "/tmp/h")])).unwrap();
assert_eq!(p, PathBuf::from("/tmp/x/oxide/editor.ron"));
}
#[test]
fn config_path_falls_back_to_home_dot_config() {
let p = resolve_config_path(env(&[("HOME", "/tmp/h")])).unwrap();
assert_eq!(p, PathBuf::from("/tmp/h/.config/oxide/editor.ron"));
}
#[test]
fn config_path_is_none_when_no_env_available() {
let p = resolve_config_path(env(&[]));
assert!(p.is_none());
}
#[test]
fn save_then_load_round_trips_the_exported_map() {
// Direct file I/O test that doesn't go through config_path — write
// to a temp file with a known shape and confirm the RON round-trip
// matches what `Settings::export` produces.
let scratch = std::env::temp_dir().join(format!(
"oxide_editor_prefs_roundtrip_{}.ron",
std::process::id()
));
let _ = std::fs::remove_file(&scratch);
let mut map = BTreeMap::new();
map.insert(
"input.bindings".to_string(),
"(bindings: {\"Jump\": [Key(KeyW)]})".to_string(),
);
let text = ron::ser::to_string_pretty(&map, ron::ser::PrettyConfig::default()).unwrap();
std::fs::write(&scratch, &text).unwrap();
let read_back = std::fs::read_to_string(&scratch).unwrap();
let parsed: BTreeMap<String, String> = ron::from_str(&read_back).unwrap();
assert_eq!(parsed, map);
let _ = std::fs::remove_file(&scratch);
}
}
+312
View File
@@ -0,0 +1,312 @@
//! A PTY-backed terminal: runs an interactive program (a shell, a REPL, an
//! AI-agent CLI like `claude`) inside the editor.
//!
//! This is the interactive counterpart to the command [console](crate::console).
//! The console pipes a one-shot command's output; a real terminal needs a
//! **pseudo-terminal**: programs detect a tty and switch to full-screen/TUI mode,
//! read raw keystrokes from stdin, and drive the screen with ANSI/VT escape
//! sequences. So this module:
//!
//! - opens a PTY with [`portable-pty`] (cross-platform — Linux now, Windows
//! later) and spawns the program attached to it;
//! - feeds the program's byte stream into a [`vt100`] parser on a reader thread,
//! which maintains the on-screen grid (cells, colours, cursor);
//! - exposes the grid for the egui panel to render, and [`send_input`] to write
//! keystrokes back to the program.
//!
//! [`send_input`]: PtyTerminal::send_input
//!
//! The two pure pieces — encoding an egui key press into the bytes a terminal
//! expects ([`encode_key`]) and mapping a [`vt100`] colour to an egui colour
//! ([`vt_color`]) — are unit-tested; the rendering/input loop itself is the
//! eye-checked part.
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use egui::{Key, Modifiers};
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize};
/// A live terminal session: the spawned child, its PTY, and the parsed screen.
pub struct PtyTerminal {
/// A short label for the session (e.g. the program name) shown on the tab.
pub title: String,
/// The parsed terminal screen, updated by the reader thread.
parser: Arc<Mutex<vt100::Parser>>,
/// The PTY master — kept for resizing.
master: Box<dyn MasterPty + Send>,
/// Writes keystrokes to the program (the PTY input side).
writer: Box<dyn Write + Send>,
/// The spawned child — killed on drop so closing the panel ends the program.
child: Box<dyn Child + Send + Sync>,
/// Current grid size, so we only resize on an actual change.
rows: u16,
cols: u16,
}
impl PtyTerminal {
/// Spawns `program` (with `args`) attached to a fresh PTY of `rows`×`cols`,
/// running in `cwd`. `title` labels the session.
pub fn spawn(
title: impl Into<String>,
program: &str,
args: &[&str],
cwd: &std::path::Path,
rows: u16,
cols: u16,
) -> std::io::Result<Self> {
let pty_system = portable_pty::native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(to_io)?;
let mut cmd = CommandBuilder::new(program);
cmd.args(args);
cmd.cwd(cwd);
// Advertise a capable terminal so programs emit colour + use full-screen
// mode; without this many tools fall back to dumb output.
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd).map_err(to_io)?;
// Drop the slave handle so the master sees EOF when the child exits.
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(to_io)?;
let writer = pair.master.take_writer().map_err(to_io)?;
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
spawn_reader(reader, parser.clone());
Ok(Self {
title: title.into(),
parser,
master: pair.master,
writer,
child,
rows,
cols,
})
}
/// Borrows the parsed screen state for rendering (locks the parser).
pub fn with_screen<R>(&self, f: impl FnOnce(&vt100::Screen) -> R) -> R {
let parser = self.parser.lock().unwrap();
f(parser.screen())
}
/// The current grid size in (rows, cols).
pub fn size(&self) -> (u16, u16) {
(self.rows, self.cols)
}
/// Writes raw bytes (already terminal-encoded) to the program's input.
pub fn send_input(&mut self, bytes: &[u8]) {
let _ = self.writer.write_all(bytes);
let _ = self.writer.flush();
}
/// Resizes the PTY and parser to `rows`×`cols` (no-op if unchanged). Programs
/// receive `SIGWINCH` and redraw to the new size.
pub fn resize(&mut self, rows: u16, cols: u16) {
if rows == 0 || cols == 0 || (rows == self.rows && cols == self.cols) {
return;
}
self.rows = rows;
self.cols = cols;
let _ = self.master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
self.parser
.lock()
.unwrap()
.screen_mut()
.set_size(rows, cols);
}
/// Whether the child program has exited.
pub fn has_exited(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(Some(_)))
}
}
impl Drop for PtyTerminal {
fn drop(&mut self) {
// End the program when the panel/session goes away.
let _ = self.child.kill();
}
}
/// Spawns the reader thread: pumps the PTY's output into the `vt100` parser until
/// EOF (the child exited / the master closed).
fn spawn_reader(mut reader: Box<dyn Read + Send>, parser: Arc<Mutex<vt100::Parser>>) {
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => parser.lock().unwrap().process(&buf[..n]),
}
}
});
}
/// Adapts a `portable_pty` error into `std::io::Error`.
fn to_io(err: impl std::fmt::Display) -> std::io::Error {
std::io::Error::other(err.to_string())
}
/// Encodes an egui [`Key`] press (with modifiers) into the byte sequence a
/// terminal program expects on stdin, or `None` for keys we don't translate
/// (printable characters arrive separately as text input events).
///
/// Covers the control keys a TUI needs: Enter, Backspace, Tab, Esc, the arrows
/// and navigation keys (as ANSI CSI sequences), and `Ctrl`+letter (which maps to
/// control codes 0x010x1A — e.g. `Ctrl+C` → `0x03`).
pub fn encode_key(key: Key, mods: Modifiers) -> Option<Vec<u8>> {
// Ctrl + A..Z -> 0x01..0x1A (Ctrl+C = ETX = 0x03, etc.).
if mods.ctrl && !mods.alt {
if let Some(letter) = letter_index(key) {
return Some(vec![letter + 1]); // 'a' -> 1
}
}
let bytes: &[u8] = match key {
Key::Enter => b"\r",
Key::Backspace => b"\x7f",
Key::Tab => b"\t",
Key::Escape => b"\x1b",
Key::ArrowUp => b"\x1b[A",
Key::ArrowDown => b"\x1b[B",
Key::ArrowRight => b"\x1b[C",
Key::ArrowLeft => b"\x1b[D",
Key::Home => b"\x1b[H",
Key::End => b"\x1b[F",
Key::PageUp => b"\x1b[5~",
Key::PageDown => b"\x1b[6~",
Key::Delete => b"\x1b[3~",
Key::Insert => b"\x1b[2~",
_ => return None,
};
Some(bytes.to_vec())
}
/// The 0-based index (`a`=0 … `z`=25) of an alphabetic [`Key`], else `None`.
/// Used to map `Ctrl`+letter to its control code.
fn letter_index(key: Key) -> Option<u8> {
let name = key.name(); // "A".."Z" for letter keys
let bytes = name.as_bytes();
if bytes.len() == 1 && bytes[0].is_ascii_uppercase() {
Some(bytes[0] - b'A')
} else {
None
}
}
/// Maps a [`vt100`] colour to an egui colour, given the default foreground to use
/// for [`vt100::Color::Default`].
pub fn vt_color(color: vt100::Color, default: egui::Color32) -> egui::Color32 {
match color {
vt100::Color::Default => default,
vt100::Color::Rgb(r, g, b) => egui::Color32::from_rgb(r, g, b),
vt100::Color::Idx(i) => ansi_indexed(i),
}
}
/// The RGB for an ANSI 256-colour palette index: the 16 base colours, the
/// 6×6×6 colour cube, and the 24-step grey ramp.
fn ansi_indexed(i: u8) -> egui::Color32 {
match i {
// Standard + bright 16-colour palette.
0 => egui::Color32::from_rgb(0x00, 0x00, 0x00),
1 => egui::Color32::from_rgb(0xCD, 0x00, 0x00),
2 => egui::Color32::from_rgb(0x00, 0xCD, 0x00),
3 => egui::Color32::from_rgb(0xCD, 0xCD, 0x00),
4 => egui::Color32::from_rgb(0x00, 0x00, 0xEE),
5 => egui::Color32::from_rgb(0xCD, 0x00, 0xCD),
6 => egui::Color32::from_rgb(0x00, 0xCD, 0xCD),
7 => egui::Color32::from_rgb(0xE5, 0xE5, 0xE5),
8 => egui::Color32::from_rgb(0x7F, 0x7F, 0x7F),
9 => egui::Color32::from_rgb(0xFF, 0x00, 0x00),
10 => egui::Color32::from_rgb(0x00, 0xFF, 0x00),
11 => egui::Color32::from_rgb(0xFF, 0xFF, 0x00),
12 => egui::Color32::from_rgb(0x5C, 0x5C, 0xFF),
13 => egui::Color32::from_rgb(0xFF, 0x00, 0xFF),
14 => egui::Color32::from_rgb(0x00, 0xFF, 0xFF),
15 => egui::Color32::from_rgb(0xFF, 0xFF, 0xFF),
// 6×6×6 colour cube (indices 16..=231).
16..=231 => {
let i = i - 16;
let steps = [0u8, 95, 135, 175, 215, 255];
let r = steps[(i / 36) as usize];
let g = steps[((i / 6) % 6) as usize];
let b = steps[(i % 6) as usize];
egui::Color32::from_rgb(r, g, b)
}
// 24-step grey ramp (indices 232..=255).
_ => {
let level = 8 + (i - 232) * 10;
egui::Color32::from_gray(level)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ctrl_c_encodes_to_etx() {
assert_eq!(encode_key(Key::C, Modifiers::CTRL), Some(vec![0x03]));
assert_eq!(encode_key(Key::A, Modifiers::CTRL), Some(vec![0x01]));
}
#[test]
fn control_keys_encode_to_their_sequences() {
assert_eq!(
encode_key(Key::Enter, Modifiers::NONE),
Some(b"\r".to_vec())
);
assert_eq!(
encode_key(Key::Backspace, Modifiers::NONE),
Some(b"\x7f".to_vec())
);
assert_eq!(
encode_key(Key::ArrowUp, Modifiers::NONE),
Some(b"\x1b[A".to_vec())
);
}
#[test]
fn plain_letters_are_not_encoded_here() {
// Printable text comes through egui text-input events, not key encoding.
assert_eq!(encode_key(Key::A, Modifiers::NONE), None);
}
#[test]
fn vt_default_color_uses_the_supplied_default() {
let dflt = egui::Color32::from_rgb(1, 2, 3);
assert_eq!(vt_color(vt100::Color::Default, dflt), dflt);
assert_eq!(
vt_color(vt100::Color::Rgb(10, 20, 30), dflt),
egui::Color32::from_rgb(10, 20, 30)
);
}
#[test]
fn ansi_cube_and_grey_indices_map_in_range() {
// Index 16 is the bottom of the cube = black; 231 is white.
assert_eq!(ansi_indexed(16), egui::Color32::from_rgb(0, 0, 0));
assert_eq!(ansi_indexed(231), egui::Color32::from_rgb(255, 255, 255));
// Greyscale ramp stays grey (r == g == b).
let g = ansi_indexed(240);
assert_eq!(g.r(), g.g());
assert_eq!(g.g(), g.b());
}
}
+6042
View File
File diff suppressed because it is too large Load Diff
+593
View File
@@ -0,0 +1,593 @@
//! The editor's mutable runtime state.
//!
//! Split out from the shell so [commands](crate::commands) can mutate exactly
//! the data that participates in undo/redo without taking a borrow of the
//! whole shell (which also owns dock layout, dialog flags, and UI buffers).
//!
//! `EditorState` is the `C` parameter every editor `Command<C>` uses.
use std::collections::HashMap;
use oxide_engine::asset::{AssetDatabase, AssetServer};
use oxide_engine::input::{ActionMap, ActionOverrides};
use oxide_engine::layer::{GroupRegistry, LayerRegistry};
use oxide_engine::prelude::*;
use oxide_engine::project::{Project, RecentProjects};
use oxide_engine::reflect::TypeRegistry;
use oxide_engine::settings::Settings;
use crate::bindings;
use crate::gizmo::{GizmoDrag, GizmoMode, SnapSettings};
/// The data the editor mutates over a session: the scene the user is editing,
/// the current selection, the asset server, the open project (if any), the
/// typed settings store, and the editor's input action bindings.
///
/// Held by the shell; commands operate on `&mut EditorState` so the change is
/// guaranteed to flow through the same pipeline whether the user clicks a
/// menu, drags a gizmo, or runs a script (Stage 10).
pub struct EditorState {
/// The scene currently open in the viewport / hierarchy.
pub scene: Scene,
/// The entity the inspector is bound to, if any.
pub selected: Option<Entity>,
/// The asset server shared by every loader (gltf, future texture/audio).
/// Cloneable [`Arc`-backed handle](oxide_engine::asset::AssetServer) — cheap
/// to hand to the file watcher.
pub assets: AssetServer,
/// The typed settings store. The shell registers core sections at startup
/// (including the [`SETTINGS_SECTION`](crate::bindings::SETTINGS_SECTION)
/// for [`actions`](Self::actions)) and modules add their own through the
/// [extension API](crate::extension).
pub settings: Settings,
/// The editor's input action bindings (camera, future gizmo hotkeys, …).
/// Default bindings are registered by
/// [`bindings::register_defaults`](crate::bindings::register_defaults);
/// the preferences UI reads / mutates this map directly, and the
/// [`ActionOverrides`](oxide_engine::input::ActionOverrides) settings
/// section stays in sync so a write-back through
/// [`Settings::export`](oxide_engine::settings::Settings::export)
/// captures the user's remap.
pub actions: ActionMap,
/// The open project, if any. `None` means the user is working in an
/// unsaved scratch scene (handy for quick tinkering before saving).
pub project: Option<Project>,
/// The open project's asset database — the bridge between stable asset
/// references (`AssetUid`/[`AssetRef<T>`](oxide_engine::asset::AssetRef)) and
/// files under `assets/`. `Some` exactly when a [`project`](Self::project)
/// is open; the shell scans it on open and rescans when the file watcher
/// reports asset changes. The asset browser lists from it and the inspector
/// asset-picker resolves through it.
pub asset_db: Option<AssetDatabase>,
/// The cross-session most-recently-used project list shown in the
/// `File / Open Recent` submenu.
pub recent: RecentProjects,
/// Transform-gizmo UI state: active tool (translate / rotate / scale),
/// snap settings, and the in-progress drag if any. The viewport reads
/// this each frame to paint handles and dispatch drags; the inspector
/// reads it to highlight the active axis. Default is
/// [`GizmoMode::Translate`] with the default [`SnapSettings`].
pub gizmo: GizmoState,
/// The reflection registry that lets the inspector edit any registered
/// component generically — list an entity's components, enumerate each
/// one's fields, and get/set a single field by name. Seeded with the
/// built-in reflected types (`Transform`, `Node`); modules add their own
/// through the extension API. This is what makes the inspector
/// reflection-driven instead of hand-coded per type.
pub registry: TypeRegistry,
/// Per-entity inspector order for **modular** components (the ones the
/// user adds and reorders). Entries persist across re-selection. Anything
/// currently on the entity that isn't in the map is appended in whatever
/// order the registry reports it, so components inserted outside the
/// inspector (e.g. by a script or `set_ron`) still show up.
///
/// *Node-baked* components — `Node`, `Transform`, `Layer` — render in a
/// fixed canonical order above this list and are not tracked here.
pub component_order: HashMap<Entity, Vec<&'static str>>,
/// Project-wide layer names (which single layer each entity's [`Layer`]
/// index means). Seeded with a small common set (`Default`, `UI`, `Player`,
/// `World`); later work persists this to the open project's settings so a
/// team can name layers like Unity's Layer Inspector. Layers are the
/// *single-valued* membership concept — one per entity.
pub layer_registry: LayerRegistry,
/// Project-wide gameplay group names — the *multi-valued* counterpart to
/// [`layer_registry`](Self::layer_registry). An entity is on one layer but
/// in any number of groups (stored in its
/// [`Tags`](oxide_engine::layer::Tags) component). The registry is the
/// project's fixed vocabulary, so the inspector offers groups to pick from
/// rather than free-typed strings. Empty until the user defines groups in
/// the Groups editor.
pub group_registry: GroupRegistry,
/// The UI document currently open in the **UI Canvas** panel, if any. The
/// canvas edits this `UiPanel`'s widget tree (via the
/// [`WidgetPath`](oxide_engine::ui::WidgetPath) authoring primitives) and
/// saves it as a `ui/` asset. `None` means the canvas shows its empty state.
pub ui_doc: Option<UiDoc>,
/// Named spawn templates backing the hierarchy's add-menu. Seeded with the
/// built-in prefabs (`Empty`, `Cube`, `Sphere`, `Plane`, `Camera`,
/// `Directional Light`); each spawns an entity already carrying the
/// matching components via the reflection [`registry`](Self::registry).
pub prefab_registry: PrefabRegistry,
/// Whether the editor is editing, playing, or paused (Stage 8.7). Drives
/// whether the host runner ticks the engine [`Schedule`] and gates the
/// play toolbar. Always [`PlayState::Editing`] at startup.
pub play: PlayState,
/// The scene as it was the instant **Play** was pressed, used to restore it
/// bit-for-bit on **Stop** so play-mode mutations never corrupt the authored
/// scene. `Some` exactly while [`play`](Self::play) is not
/// [`Editing`](PlayState::Editing). See [`enter_play`](Self::enter_play) /
/// [`stop`](Self::stop).
pub play_snapshot: Option<SceneSnapshot>,
}
/// Whether the editor is authoring the scene or running it (Stage 8.7).
///
/// In [`Playing`](Self::Playing) the host runner ticks the engine
/// [`Schedule`](oxide_engine::app::Schedule) each frame; [`Paused`](Self::Paused)
/// freezes ticking but keeps the scene live so a single **Step** can advance one
/// fixed tick and the inspector can still edit fields; [`Editing`](Self::Editing)
/// is the normal authoring state where no systems run. Pressing **Play**
/// snapshots the scene and pressing **Stop** restores it (see
/// [`EditorState::enter_play`] / [`EditorState::stop`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlayState {
/// Authoring; the engine schedule is not ticked.
#[default]
Editing,
/// Running; the schedule is ticked every frame.
Playing,
/// Running but frozen; the schedule is ticked only one fixed step per Step.
Paused,
}
/// Editor-only transform-gizmo state held on [`EditorState`].
///
/// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays
/// pure-logic (rays in, transforms out) and this struct carries only the
/// per-session UI choices.
pub struct GizmoState {
/// Which tool is active (toggle with W / E / R while the cursor is
/// over the Viewport tab and the camera is in orbit mode).
pub mode: GizmoMode,
/// The snap step sizes applied during a drag while the snap modifier
/// (Ctrl by default) is held.
pub snap: SnapSettings,
/// `Some` while the user is mid-drag on a handle; the runner
/// recomputes the target's transform each frame via
/// [`crate::gizmo::apply_drag`].
pub drag: Option<GizmoDrag>,
}
impl Default for GizmoState {
fn default() -> Self {
Self {
mode: GizmoMode::Translate,
snap: SnapSettings::default(),
drag: None,
}
}
}
/// An open UI document in the editor's **UI Canvas**.
///
/// Holds the [`UiPanel`] being authored, the asset it loads from / saves to (if
/// it has been saved), the currently selected widget (by
/// [`WidgetPath`](oxide_engine::ui::WidgetPath)), and whether there are unsaved
/// edits. The same `UiPanel` RON the canvas writes is what the runtime loads.
pub struct UiDoc {
/// The panel (widget tree + pixel/world size) being edited.
pub panel: UiPanel,
/// The `ui/` asset this document is saved as, once saved.
pub asset: Option<AssetUid>,
/// The widget the property panel is bound to (root by default).
pub selected: WidgetPath,
/// Whether the document has edits not yet written to disk.
pub dirty: bool,
}
impl UiDoc {
/// A new, empty document: a single full-bleed column root at a 1280×720
/// authoring resolution. Not yet associated with an asset.
pub fn new() -> Self {
let root = Widget::column().with_id("root").with_style(UiLayoutStyle {
width: UiSizing::Grow(1.0),
height: UiSizing::Grow(1.0),
..Default::default()
});
Self {
panel: UiPanel::new(root, Vec2::new(1280.0, 720.0), Vec2::new(2.0, 1.125)),
asset: None,
selected: WidgetPath::root(),
dirty: false,
}
}
}
impl Default for UiDoc {
fn default() -> Self {
Self::new()
}
}
impl EditorState {
/// A blank state with an empty scene, no open project, and the editor's
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
/// sprint — see [`bindings`](crate::bindings)).
pub fn new() -> Self {
Self::with_scene(Scene::new())
}
/// Like [`new`](Self::new) but starting from a populated scene — used by
/// the shell so the editor has something visible on launch.
pub fn with_scene(scene: Scene) -> Self {
let mut actions = ActionMap::new();
bindings::register_defaults(&mut actions);
let mut settings = Settings::new();
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
let mut registry = TypeRegistry::new();
register_builtin_types(&mut registry);
// Seed a small, generally-useful set of named layers (besides the
// built-in "Default" at index 0). These are common filter slots, not
// generic "Layer 1 / Layer 2" filler; the user renames or extends them
// in the Layer Names editor.
let mut layer_registry = LayerRegistry::new();
layer_registry.set(1, "UI");
layer_registry.set(2, "Player");
layer_registry.set(3, "World");
Self {
scene,
selected: None,
assets: AssetServer::new(),
settings,
actions,
project: None,
asset_db: None,
recent: RecentProjects::new(8),
gizmo: GizmoState::default(),
registry,
component_order: HashMap::new(),
layer_registry,
group_registry: GroupRegistry::new(),
ui_doc: None,
prefab_registry: builtin_prefabs(),
play: PlayState::Editing,
play_snapshot: None,
}
}
/// Whether the editor is currently running the scene
/// ([`Playing`](PlayState::Playing) or [`Paused`](PlayState::Paused)) — the
/// states in which the authored scene is "live" and will be restored on Stop.
pub fn is_in_play(&self) -> bool {
self.play != PlayState::Editing
}
/// Enters **Play**: snapshots the current scene (so Stop can restore it) and
/// transitions to [`Playing`](PlayState::Playing). No-op if already playing
/// or paused — re-entering must not overwrite the original snapshot.
pub fn enter_play(&mut self) {
if self.is_in_play() {
return;
}
self.play_snapshot = Some(self.scene.snapshot(&self.registry));
self.play = PlayState::Playing;
}
/// Toggles between [`Playing`](PlayState::Playing) and
/// [`Paused`](PlayState::Paused). No-op while [`Editing`](PlayState::Editing)
/// (there is nothing to pause).
pub fn toggle_pause(&mut self) {
self.play = match self.play {
PlayState::Playing => PlayState::Paused,
PlayState::Paused => PlayState::Playing,
PlayState::Editing => return,
};
}
/// Stops play and restores the scene to its pre-play snapshot bit-for-bit,
/// then returns to [`Editing`](PlayState::Editing). The restored scene has
/// fresh entity handles, so the selection and any in-flight gizmo drag are
/// cleared (the old [`Entity`] no longer exists). No-op while already
/// editing.
///
/// A failed restore (corrupt component RON) leaves the live scene in place
/// but still returns to editing; the caller may log the returned error.
pub fn stop(&mut self) -> Result<(), SceneError> {
if !self.is_in_play() {
return Ok(());
}
let result = match self.play_snapshot.take() {
Some(snapshot) => snapshot.restore(&self.registry).map(|scene| {
self.scene = scene;
}),
None => Ok(()),
};
self.selected = None;
self.gizmo.drag = None;
self.play = PlayState::Editing;
result
}
/// Mirrors the current [`actions`](Self::actions) overrides into the
/// `input.bindings` settings section so the next
/// [`Settings::export`](oxide_engine::settings::Settings::export) round-
/// trips them. Called by the shell after every binding edit.
pub fn sync_action_overrides_to_settings(&mut self) {
let overrides = self.actions.overrides();
self.settings
.set::<ActionOverrides>(bindings::SETTINGS_SECTION, overrides);
}
/// Applies any [`ActionOverrides`] previously
/// [`Settings::import`](oxide_engine::settings::Settings::import)'d into
/// the `input.bindings` section on top of the registered defaults.
/// Called by the host runner at startup, after loading the on-disk
/// preferences file. No-op if the section is empty or unregistered.
pub fn apply_action_overrides_from_settings(&mut self) {
if let Some(o) = self
.settings
.get::<ActionOverrides>(bindings::SETTINGS_SECTION)
{
// Clone to release the immutable borrow before mutating actions.
let o = o.clone();
self.actions.apply_overrides(&o);
}
}
}
impl Default for EditorState {
fn default() -> Self {
Self::new()
}
}
/// Registers the engine's built-in reflected component types under stable
/// names. Kept separate so the shell (and tests) seed a registry identically,
/// and so modules layer their own `register_reflected` calls on top.
///
/// Transform and Node are reflected but **not** addable (every scene entity
/// already carries them). `MeshRenderer` is addable, so it shows up in the
/// inspector's "Add Component" menu and is copied by Duplicate. `PrimitiveShape`
/// registers as an enum so its inspector widget is a dropdown.
fn register_builtin_types(registry: &mut TypeRegistry) {
// Node-baked components: reflected so the inspector can read/write them,
// but **not** addable — every entity carries them inherently
// (auto-attached on `Scene::spawn`), so the Add Component menu must not
// offer to attach a duplicate.
registry.register_reflected::<Transform>("Transform");
registry.register_reflected::<Node>("Node");
registry.register_reflected::<oxide_engine::layer::Layer>("Layer");
// Modular components: addable from the inspector. Having several distinct
// addable types is what lets the user attach more than one component to a
// node and drag-reorder them (an archetypal ECS allows only one component
// of a given type per entity, so a *second* mesh lives on a child — see the
// Add Component menu's "as child" path).
registry.register_addable::<oxide_engine::render::MeshRenderer>("MeshRenderer");
registry.register_enum::<oxide_engine::render::PrimitiveShape>("PrimitiveShape");
registry.register_addable::<oxide_engine::render::Camera>("Camera");
registry.register_addable::<oxide_engine::render::DirectionalLight>("DirectionalLight");
// Stage-9 physics components: addable from the inspector and captured by the
// play-mode snapshot (so Stop reverts a simulated body). No per-type editor
// code — the reflection-driven inspector renders them from their fields, with
// the two shape/kind enums shown as dropdowns.
registry.register_addable::<oxide_physics::RigidBody>("RigidBody");
registry.register_enum::<oxide_physics::RigidBodyKind>("RigidBodyKind");
registry.register_addable::<oxide_physics::Collider>("Collider");
registry.register_enum::<oxide_physics::ColliderShape>("ColliderShape");
registry.register_addable::<oxide_physics::CharacterController>("CharacterController");
// Stage-10 scripting: the Script component is addable from the inspector and
// captured by the play-mode snapshot (so Stop reverts a script attach/detach).
// Its `source` field is an `AssetRef<ScriptAsset>`, which the inspector shows
// as a picker filtered to the `scripts/` folder.
registry.register_addable::<oxide_script::Script>("Script");
}
/// The built-in prefabs the hierarchy add-menu offers. Data-driven via
/// [`ComponentSpec`]: each prefab is a node name plus the components to attach,
/// applied on spawn through the reflection registry. The type names here must
/// match those registered in [`register_builtin_types`].
fn builtin_prefabs() -> PrefabRegistry {
use oxide_engine::render::{Camera, DirectionalLight, MeshRenderer, PrimitiveShape};
let mut reg = PrefabRegistry::new();
// A bare node — just the node-baked Node/Transform/Layer.
reg.register(Prefab::new("Empty"));
// Primitive meshes (each a MeshRenderer with the matching shape).
for (name, shape) in [
("Cube", PrimitiveShape::Cube),
("Sphere", PrimitiveShape::Sphere),
("Plane", PrimitiveShape::Plane),
] {
let mesh = MeshRenderer {
shape,
..MeshRenderer::default()
};
if let Some(spec) = ComponentSpec::of("MeshRenderer", &mesh) {
reg.register(Prefab::new(name).with(spec));
}
}
// Viewpoint + light entities.
if let Some(spec) = ComponentSpec::of("Camera", &Camera::default()) {
reg.register(Prefab::new("Camera").with(spec));
}
if let Some(spec) = ComponentSpec::of("DirectionalLight", &DirectionalLight::default()) {
reg.register(Prefab::new("Directional Light").with(spec));
}
reg
}
#[cfg(test)]
mod tests {
use super::*;
use oxide_engine::math::{Transform, Vec3};
/// An editor state with one entity, ready to play.
fn state_with_entity() -> (EditorState, Entity) {
let mut state = EditorState::new();
let e = state.scene.spawn("thing", Transform::IDENTITY);
(state, e)
}
#[test]
fn enter_play_snapshots_and_sets_playing() {
let (mut state, _) = state_with_entity();
assert_eq!(state.play, PlayState::Editing);
assert!(state.play_snapshot.is_none());
state.enter_play();
assert_eq!(state.play, PlayState::Playing);
assert!(state.play_snapshot.is_some());
}
#[test]
fn re_entering_play_does_not_overwrite_the_snapshot() {
let (mut state, e) = state_with_entity();
state.enter_play();
let original = state.play_snapshot.clone();
// Mutate, then (defensively) call enter_play again — the snapshot must
// remain the *pre-play* one so Stop still reverts correctly.
state
.scene
.set_local_transform(e, Transform::from_translation(Vec3::X));
state.enter_play();
assert_eq!(state.play_snapshot, original);
}
#[test]
fn toggle_pause_flips_only_while_in_play() {
let (mut state, _) = state_with_entity();
// No-op while editing.
state.toggle_pause();
assert_eq!(state.play, PlayState::Editing);
state.enter_play();
state.toggle_pause();
assert_eq!(state.play, PlayState::Paused);
state.toggle_pause();
assert_eq!(state.play, PlayState::Playing);
}
#[test]
fn stop_restores_the_scene_and_clears_play_state() {
let (mut state, e) = state_with_entity();
let before = state.scene.to_ron().unwrap();
state.selected = Some(e);
state.enter_play();
// Simulate a play-mode mutation (as a tick would).
state
.scene
.set_local_transform(e, Transform::from_translation(Vec3::new(5.0, 0.0, 0.0)));
assert_ne!(state.scene.to_ron().unwrap(), before);
state.stop().unwrap();
assert_eq!(state.play, PlayState::Editing);
assert!(state.play_snapshot.is_none());
// Scene reverted bit-for-bit; selection dropped (handles changed).
assert_eq!(state.scene.to_ron().unwrap(), before);
assert!(state.selected.is_none());
}
#[test]
fn stop_while_editing_is_a_noop() {
let (mut state, _) = state_with_entity();
let before = state.scene.to_ron().unwrap();
state.stop().unwrap();
assert_eq!(state.play, PlayState::Editing);
assert_eq!(state.scene.to_ron().unwrap(), before);
}
#[test]
fn physics_components_are_addable_and_reflected() {
let state = EditorState::new();
// Editable via the reflection-driven inspector and offered in the Add
// Component menu (addable), with no per-type editor code.
for name in ["RigidBody", "Collider", "CharacterController"] {
assert!(state.registry.is_registered(name), "{name} not registered");
}
}
#[test]
fn the_script_component_is_addable_and_reflected() {
// Stage-10 dual-editability: Script is registered like any other
// component, so the inspector offers it in Add Component and renders its
// fields generically.
let state = EditorState::new();
assert!(state.registry.is_registered("Script"));
}
#[test]
fn stop_reverts_a_script_attach() {
// Attaching a Script during play must be undone on Stop — the snapshot
// captures the reflected Script component like any other.
let mut state = EditorState::new();
let e = state.scene.spawn("scripted", Transform::IDENTITY);
state.enter_play();
// The "running game" attaches a script at play time.
state
.scene
.world_mut()
.insert_one(e, oxide_script::Script::default())
.unwrap();
state.stop().unwrap();
let restored = state
.scene
.entities()
.find(|&e| state.scene.name(e).as_deref() == Some("scripted"))
.expect("the entity should be restored");
assert!(
state.scene.get::<oxide_script::Script>(restored).is_none(),
"the play-time script attach should be reverted on Stop"
);
}
#[test]
fn stop_reverts_a_simulated_physics_body() {
// A body that "fell" during play must be restored on Stop — the snapshot
// captures reflected physics components like any other.
let mut state = EditorState::new();
let e = state.scene.spawn(
"ball",
Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)),
);
state
.scene
.world_mut()
.insert_one(e, oxide_physics::RigidBody::default())
.unwrap();
state
.scene
.world_mut()
.insert_one(e, oxide_physics::Collider::ball(0.5))
.unwrap();
state.enter_play();
// Simulate physics moving the body down (as the play tick would).
state
.scene
.set_local_transform(e, Transform::from_translation(Vec3::new(0.0, 1.0, 0.0)));
state.stop().unwrap();
// Snapshot restore respawns entities (handles change), so find by name
// and confirm both the Transform and the physics components came back.
let restored = state
.scene
.entities()
.find(|&e| state.scene.name(e).as_deref() == Some("ball"))
.expect("the ball entity should be restored");
assert_eq!(
state.scene.world_transform(restored).unwrap().translation,
Vec3::new(0.0, 5.0, 0.0),
"transform should revert to the pre-play pose"
);
let collider = state
.scene
.get::<oxide_physics::Collider>(restored)
.expect("the Collider component should be restored");
assert_eq!(collider.radius, 0.5);
assert!(state
.scene
.get::<oxide_physics::RigidBody>(restored)
.is_some());
}
}
+105
View File
@@ -0,0 +1,105 @@
//! The editor's command terminal: runs a shell command and streams its output
//! into the [Console](crate::console) panel.
//!
//! This is the second half of the Stage-10 editor terminal — the log-capture
//! Console shows engine/script output, and this adds **command execution**: type
//! a command, it runs (via `sh -c`) with the working directory set to the open
//! project, and its stdout/stderr stream back into the same panel as they
//! arrive. Long-running commands (a build, a watcher, an AI-agent CLI) stream
//! line by line rather than blocking the editor — each line is pushed to the
//! shared console buffer from a reader thread, and the panel re-renders it next
//! frame.
//!
//! Running arbitrary commands from the editor is intended: the terminal is the
//! drop-in surface for dev tools and AI agents that edit the watched scripts
//! (whose edits then flow back through live reload).
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Command, Stdio};
use log::Level;
use crate::console;
/// The console target terminal lines are tagged with (distinguishes shell output
/// from engine `log` records in the panel).
const TARGET: &str = "terminal";
/// Spawns `command` with `sh -c` in `cwd`, streaming its stdout/stderr into the
/// console. Returns immediately; output arrives asynchronously. A blank command
/// is ignored.
///
/// The command is echoed first (`$ <command>`); stdout lines log at info level,
/// stderr at warn (so errors stand out), and the exit status is reported when
/// the process finishes.
pub fn run(command: &str, cwd: &Path) {
let command = command.trim();
if command.is_empty() {
return;
}
console::append(Level::Info, TARGET, format!("$ {command}"));
let child = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match child {
Ok(child) => child,
Err(err) => {
console::append(Level::Error, TARGET, format!("failed to start: {err}"));
return;
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// One supervisor thread owns the child: it streams both pipes (stderr on its
// own thread so the two don't deadlock on full buffers), waits, and reports
// the exit status. Detached — the panel reads results from the shared buffer.
std::thread::spawn(move || {
let err_thread = stderr.map(|e| std::thread::spawn(move || stream(e, Level::Warn)));
if let Some(out) = stdout {
stream(out, Level::Info);
}
if let Some(handle) = err_thread {
let _ = handle.join();
}
match child.wait() {
Ok(status) if status.success() => {
console::append(Level::Info, TARGET, "(exit 0)");
}
Ok(status) => {
let code = status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
console::append(Level::Warn, TARGET, format!("(exit {code})"));
}
Err(err) => console::append(Level::Error, TARGET, format!("wait failed: {err}")),
}
});
}
/// Reads `reader` line by line, pushing each line into the console at `level`.
fn stream<R: Read>(reader: R, level: Level) {
let mut buf = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match buf.read_line(&mut line) {
Ok(0) => break, // EOF
Ok(_) => console::append(
level,
TARGET,
line.trim_end_matches(['\n', '\r']).to_string(),
),
Err(_) => break,
}
}
}
+451
View File
@@ -0,0 +1,451 @@
//! The editor's 3D viewport: an orbit / flythrough camera and a forward
//! render of the scene.
//!
//! The engine stays UI-agnostic; this glue lives in the editor. [`Viewport`]
//! owns a [`ForwardRenderer`], a small cache of primitive [`GpuMesh`]es, two
//! camera modes ([`OrbitCamera`] for inspecting a target,
//! [`FlythroughCamera`] for free-look navigation), and draws every scene
//! entity that carries a [`MeshRenderer`](oxide_engine::render::MeshRenderer)
//! component. The mode toggle preserves pose so the camera does not snap
//! when switching.
use std::collections::HashMap;
use oxide_engine::hecs::Entity;
use oxide_engine::math::{EulerRot, Quat, Transform, Vec3};
use oxide_engine::prelude::*;
use oxide_engine::wgpu;
use oxide_engine::window::RenderCtx;
/// An orbit camera: looks at `target` from a yaw/pitch/distance offset.
pub struct OrbitCamera {
/// The point the camera orbits and looks at.
pub target: Vec3,
/// Horizontal angle (radians) around `+Y`.
pub yaw: f32,
/// Vertical angle (radians); clamped to avoid flipping over the poles.
pub pitch: f32,
/// Distance from `target` to the eye.
pub distance: f32,
}
impl Default for OrbitCamera {
fn default() -> Self {
Self {
target: Vec3::new(0.0, 0.8, 0.0),
yaw: 0.6,
pitch: -0.45,
distance: 12.0,
}
}
}
impl OrbitCamera {
/// The camera's orientation as a quaternion.
fn rotation(&self) -> Quat {
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
}
/// The eye position in world space.
fn eye(&self) -> Vec3 {
self.target + self.rotation() * Vec3::new(0.0, 0.0, self.distance)
}
/// The camera's world transform (what the renderer takes as the view).
pub fn view_transform(&self) -> Transform {
Transform::looking_at(self.eye(), self.target, Vec3::Y)
}
/// Orbit by a pixel drag delta.
pub fn orbit(&mut self, dx: f32, dy: f32) {
const SENS: f32 = 0.005;
self.yaw -= dx * SENS;
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
}
/// Pan the target in the camera's screen plane by a pixel drag delta.
pub fn pan(&mut self, dx: f32, dy: f32) {
let rot = self.rotation();
let right = rot * Vec3::X;
let up = rot * Vec3::Y;
// Scale panning with distance so it feels consistent at any zoom.
let speed = self.distance * 0.0015;
self.target += (-right * dx + up * dy) * speed;
}
/// Zoom by a scroll delta (positive = closer).
pub fn zoom(&mut self, amount: f32) {
self.distance = (self.distance * (1.0 - amount * 0.1)).clamp(0.5, 500.0);
}
}
/// A free-look "flythrough" camera: a position in world space plus a
/// yaw/pitch orientation, driven by WASD/QE translation + mouse-look in the
/// usual first-person convention.
///
/// Distinct from [`OrbitCamera`] because the two modes have fundamentally
/// different controls; switching between them preserves the camera pose via
/// [`FlythroughCamera::from_orbit`] / [`OrbitCamera::from_flythrough`] so the
/// view doesn't snap on toggle.
pub struct FlythroughCamera {
/// Eye position in world space.
pub position: Vec3,
/// Horizontal angle (radians) around `+Y`, matching [`OrbitCamera::yaw`].
pub yaw: f32,
/// Vertical angle (radians); clamped to avoid flipping over the poles.
pub pitch: f32,
/// Translation speed in world units per second at the base (non-sprint)
/// rate. Adjustable at runtime — the editor binds scroll-wheel to this.
pub move_speed: f32,
/// Multiplier applied while the "sprint" action is held.
pub sprint_multiplier: f32,
}
impl Default for FlythroughCamera {
fn default() -> Self {
// Place the eye where the default OrbitCamera would put it, so a
// fresh project that starts in flythrough mode (a future preference)
// sees the same opening view.
let orbit = OrbitCamera::default();
Self::from_orbit(&orbit)
}
}
impl FlythroughCamera {
/// Position the flythrough camera to look at the same view the given
/// orbit camera is showing. The eye lands at the orbit camera's eye
/// position and the yaw/pitch are copied verbatim.
pub fn from_orbit(orbit: &OrbitCamera) -> Self {
Self {
position: orbit.eye(),
yaw: orbit.yaw,
pitch: orbit.pitch,
move_speed: 5.0,
sprint_multiplier: 4.0,
}
}
/// The camera's orientation as a quaternion (same Y-yaw-then-X-pitch
/// convention as [`OrbitCamera::rotation`]).
fn rotation(&self) -> Quat {
Quat::from_euler(EulerRot::YXZ, self.yaw, self.pitch, 0.0)
}
/// Unit world-space forward direction (where the camera looks).
pub fn forward(&self) -> Vec3 {
self.rotation() * Vec3::new(0.0, 0.0, -1.0)
}
/// Unit world-space right direction (camera's screen-right).
pub fn right(&self) -> Vec3 {
self.rotation() * Vec3::X
}
/// Unit world-space up direction.
pub fn up(&self) -> Vec3 {
self.rotation() * Vec3::Y
}
/// The camera's world transform (what the renderer takes as the view).
pub fn view_transform(&self) -> Transform {
Transform::looking_at(self.position, self.position + self.forward(), Vec3::Y)
}
/// Mouse-look by a pixel drag delta. Same sensitivity as
/// [`OrbitCamera::orbit`] so the gesture feels identical in both modes.
pub fn look(&mut self, dx: f32, dy: f32) {
const SENS: f32 = 0.005;
self.yaw -= dx * SENS;
self.pitch = (self.pitch - dy * SENS).clamp(-1.54, 1.54);
}
/// Translate by a per-frame move vector in **camera-local** axes (`+X`
/// right, `+Y` up, `-Z` forward — the same convention game code uses for
/// a first-person move input). Each axis is expected to be in `[-1, 1]`,
/// the natural range of an [`AxisBinding`](oxide_engine::input::AxisBinding).
pub fn translate_local(&mut self, local: Vec3, dt: f32, sprint: bool) {
if local.length_squared() == 0.0 {
return;
}
let speed = if sprint {
self.move_speed * self.sprint_multiplier
} else {
self.move_speed
};
// `local` is in camera-local axes (right / up / forward). Convert to
// world by combining with the camera basis. `-Z` is forward, so a
// local.z of `-1.0` (from a "forward" axis) moves along +forward.
let world = self.right() * local.x + self.up() * local.y + self.forward() * (-local.z);
self.position += world * (speed * dt);
}
/// Adjust the base move speed by a scroll-wheel delta. Clamped so the
/// camera never becomes immobile or too fast to control.
pub fn adjust_move_speed(&mut self, scroll_lines: f32) {
let factor = (1.0 + scroll_lines * 0.1).max(0.1);
self.move_speed = (self.move_speed * factor).clamp(0.5, 200.0);
}
}
impl OrbitCamera {
/// Position an orbit camera so it shows the same view as the given
/// flythrough camera. The target is placed [`OrbitCamera::distance`]
/// units in front of the flythrough's eye along its forward direction.
pub fn from_flythrough(fly: &FlythroughCamera) -> Self {
let distance = OrbitCamera::default().distance;
Self {
target: fly.position + fly.forward() * distance,
yaw: fly.yaw,
pitch: fly.pitch,
distance,
}
}
}
/// Which input scheme drives the viewport camera.
///
/// [`Orbit`](Self::Orbit) is the default editor convention — useful for
/// inspecting a single subject. [`Flythrough`](Self::Flythrough) is a
/// first-person fly: WASD/QE translate, right-drag looks around, scroll
/// adjusts move speed; better for navigating a level or open scene.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraMode {
Orbit,
Flythrough,
}
/// Owns the renderer, primitive mesh cache, camera, and lighting for the editor
/// viewport.
pub struct Viewport {
pipeline: RenderPipeline,
meshes: HashMap<PrimitiveShape, GpuMesh>,
pub camera: Camera,
pub orbit: OrbitCamera,
pub flythrough: FlythroughCamera,
pub mode: CameraMode,
pub lighting: Lighting,
}
impl Viewport {
/// Builds the viewport, uploading a GPU mesh for every primitive shape.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let meshes = PrimitiveShape::ALL
.iter()
.map(|&shape| (shape, shape.mesh().upload(device, shape.label())))
.collect();
// The editor clears the frame before drawing the scene, so the viewport
// pipeline is just the forward pass; post passes slot in here later.
let mut pipeline = RenderPipeline::new();
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
let orbit = OrbitCamera::default();
let flythrough = FlythroughCamera::from_orbit(&orbit);
Self {
pipeline,
meshes,
camera: Camera::default(),
orbit,
flythrough,
mode: CameraMode::Orbit,
lighting: Lighting::default(),
}
}
/// The view transform of the **active** camera (whichever mode is
/// currently selected).
pub fn view_transform(&self) -> Transform {
match self.mode {
CameraMode::Orbit => self.orbit.view_transform(),
CameraMode::Flythrough => self.flythrough.view_transform(),
}
}
/// Swaps between orbit and flythrough modes while preserving pose, so
/// the visible scene does not jump when the user toggles. Returns the
/// new mode for the caller to surface in the status bar.
pub fn toggle_camera_mode(&mut self) -> CameraMode {
match self.mode {
CameraMode::Orbit => {
self.flythrough = FlythroughCamera::from_orbit(&self.orbit);
self.mode = CameraMode::Flythrough;
}
CameraMode::Flythrough => {
self.orbit = OrbitCamera::from_flythrough(&self.flythrough);
self.mode = CameraMode::Orbit;
}
}
self.mode
}
/// Renders the scene's renderable entities into the frame, before the editor
/// UI is painted on top.
///
/// `viewport_rect` restricts drawing and projection to the Viewport
/// tab's sub-rectangle of the surface (in physical pixels). `None`
/// falls back to the full surface — handy for early frames before
/// egui has reported a rect, and for any host that wants to render
/// edge-to-edge.
pub fn render(
&mut self,
scene: &Scene,
ctx: &RenderCtx<'_>,
viewport_rect: Option<oxide_engine::math::Rect>,
) {
// Snapshot renderables first so the query borrow is released before we
// resolve world transforms.
let renderables: Vec<(Entity, MeshRenderer)> = scene
.world()
.query::<&MeshRenderer>()
.iter()
.map(|(e, mr)| (e, *mr))
.collect();
let view = self.view_transform();
let mut objects = Vec::with_capacity(renderables.len());
for (entity, mr) in &renderables {
// Hierarchical: a disabled ancestor hides its whole subtree.
if !scene.is_effectively_enabled(*entity).unwrap_or(true) {
continue;
}
// Per-component: the MeshRenderer itself may be marked disabled
// (e.g. by a script before a trigger fires).
if scene.is_component_disabled(*entity, "MeshRenderer") {
continue;
}
// Honor the camera's layer visibility: entities default to the
// Default layer when they carry no explicit `Layer` component.
let layers = scene.get::<Layer>(*entity).map(|l| *l).unwrap_or_default();
if !self.camera.sees(layers) {
continue;
}
let Some(world) = scene.world_transform(*entity) else {
continue;
};
if let Some(mesh) = self.meshes.get(&mr.shape) {
objects.push(RenderObject {
mesh,
material: mr.material,
transform: world,
});
}
}
self.pipeline.render(&mut FrameContext {
device: ctx.gpu.device(),
queue: ctx.gpu.queue(),
color: ctx.view,
size: ctx.size,
viewport_rect,
clear_color: Color::BLACK, // editor clears separately; unused here
camera: &self.camera,
view_transform: &view,
lighting: &self.lighting,
objects: &objects,
});
}
/// Builds the world-space ray from `cursor` (window-physical pixels)
/// through the viewport using the active camera's projection. The same
/// helper feeds both entity picking and gizmo handle hit-testing — they
/// must agree on the math or a click on a handle won't line up with
/// what the user sees.
pub fn ray_from_cursor(
&self,
cursor: (f32, f32),
size: (u32, u32),
viewport_rect: Option<oxide_engine::math::Rect>,
) -> Ray {
let rect = viewport_rect.unwrap_or_else(|| {
oxide_engine::math::Rect::from_min_size(
oxide_engine::math::Vec2::ZERO,
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
)
});
let (w, h) = (rect.width().max(1.0), rect.height().max(1.0));
// Cursor is in window coords; rebase to viewport-local before NDC.
let local_x = cursor.0 - rect.min.x;
let local_y = cursor.1 - rect.min.y;
// Cursor → normalized device coordinates (flip Y: screen down, NDC up).
let ndc_x = 2.0 * local_x / w - 1.0;
let ndc_y = 1.0 - 2.0 * local_y / h;
let view = self.view_transform();
let inv_vp = self.camera.view_projection(w / h, &view).inverse();
// Unproject the near and far points of the pixel into world space.
let near = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 0.0));
let far = inv_vp.project_point3(Vec3::new(ndc_x, ndc_y, 1.0));
Ray::new(near, (far - near).normalize_or_zero())
}
/// The combined view-projection matrix the viewport uses for `viewport_rect`'s
/// aspect ratio. Exposed so the gizmo overlay can project world points
/// to screen pixels with the same math the renderer drew with.
pub fn view_projection_for(
&self,
viewport_rect: Option<oxide_engine::math::Rect>,
size: (u32, u32),
) -> oxide_engine::math::Mat4 {
let rect = viewport_rect.unwrap_or_else(|| {
oxide_engine::math::Rect::from_min_size(
oxide_engine::math::Vec2::ZERO,
oxide_engine::math::Vec2::new(size.0.max(1) as f32, size.1.max(1) as f32),
)
});
let aspect = rect.width().max(1.0) / rect.height().max(1.0);
let view = self.view_transform();
self.camera.view_projection(aspect, &view)
}
/// Picks the nearest renderable entity under the cursor (physical pixels),
/// by casting a ray through the viewport and testing each entity's
/// world-space bounds. Returns `None` if the ray hits nothing.
///
/// `viewport_rect` is the same sub-rectangle the render path used (the
/// Viewport tab in the editor's case); the cursor is converted to NDC
/// relative to it so a click at the tab's edge corresponds to the ray
/// through that edge — not through the corresponding spot in a full-
/// window projection. `None` falls back to the full window.
pub fn pick(
&self,
scene: &Scene,
cursor: (f32, f32),
size: (u32, u32),
viewport_rect: Option<oxide_engine::math::Rect>,
) -> Option<Entity> {
let ray = self.ray_from_cursor(cursor, size, viewport_rect);
let renderables: Vec<(Entity, MeshRenderer)> = scene
.world()
.query::<&MeshRenderer>()
.iter()
.map(|(e, mr)| (e, *mr))
.collect();
let mut best: Option<(f32, Entity)> = None;
for (entity, mr) in renderables {
// Don't pick what isn't visible (effectively disabled subtree, or
// a per-component disable on the MeshRenderer).
if scene.is_component_disabled(entity, "MeshRenderer") {
continue;
}
if !scene.is_effectively_enabled(entity).unwrap_or(true) {
continue;
}
let Some(world) = scene.world_transform(entity) else {
continue;
};
let aabb = transform_aabb(&world, &mr.shape.local_bounds());
if let Some(t) = aabb.ray_intersection(&ray) {
if best.map_or(true, |(bt, _)| t < bt) {
best = Some((t, entity));
}
}
}
best.map(|(_, e)| e)
}
}
/// The world-space AABB of a local AABB transformed by `t` (transform its 8
/// corners and re-fit).
fn transform_aabb(t: &Transform, local: &oxide_engine::math::Aabb) -> oxide_engine::math::Aabb {
oxide_engine::math::Aabb::from_points(local.corners().iter().map(|&c| t.transform_point(c)))
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "oxide-engine-derive"
description = "Derive macros for Oxide's reflection system (#[derive(Reflect)])"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
rust-version.workspace = true
[lib]
proc-macro = true
[dependencies]
syn.workspace = true
quote.workspace = true
proc-macro2.workspace = true
+257
View File
@@ -0,0 +1,257 @@
//! Derive macros for Oxide's reflection system.
//!
//! This crate exists for exactly one job: `#[derive(Reflect)]`. It is the
//! compile-time half of the engine's **dual-editable types** principle —
//! every component's public fields should be editable from the editor
//! inspector and from scripts through *one* representation, with no
//! hand-written per-type code. The runtime half (the `Reflect` trait, the
//! `FieldInfo` descriptor, and the `TypeRegistry`) lives in
//! `oxide_engine::reflect`; this crate only generates the trait impl.
//!
//! ## What the derive generates
//!
//! For a struct with named fields, `#[derive(Reflect)]` emits an
//! `oxide_engine::reflect::Reflect` impl that exposes each **public**,
//! non-skipped field as:
//!
//! - a static [`FieldInfo`] entry (`name` + syntactic `type_name`), so a
//! generic inspector can enumerate fields and pick a widget per type, and
//! - per-field RON get/set, so a single field can be read or written without
//! touching the rest of the component (the unit an inspector edits).
//!
//! Only `pub` fields are reflected — this matches the Unity/Godot convention
//! that *public* fields are the editable surface. Use `#[reflect(skip)]` to
//! exclude a public field.
//!
//! ```ignore
//! 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
//! }
//! ```
//!
//! Every reflected field must itself be `serde`-serializable, since get/set
//! round-trip through RON.
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields, Visibility};
/// Derives `oxide_engine::reflect::Reflect` for a struct with named fields.
///
/// See the [crate-level docs](crate) for the field-selection rules
/// (public-only, `#[reflect(skip)]`).
#[proc_macro_derive(Reflect, attributes(reflect))]
pub fn derive_reflect(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
// Named-field structs and tuple structs are both supported. Tuple-struct
// fields are addressed by their positional index ("0", "1", …), matching
// Rust's own `self.0` / `self.1` syntax — this lets one-field newtype
// components like `Layers(pub LayerMask)` reflect without a wrapper.
let raw_fields = match &input.data {
Data::Struct(data) => match &data.fields {
Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
Fields::Unnamed(unnamed) => unnamed.unnamed.iter().collect::<Vec<_>>(),
Fields::Unit => {
return compile_error(name, "Reflect cannot be derived for unit structs")
}
},
_ => return compile_error(name, "Reflect can only be derived for structs"),
};
let mut infos = Vec::new();
let mut get_arms = Vec::new();
let mut set_arms = Vec::new();
for (index, field) in raw_fields.iter().enumerate() {
// Public-only: private fields are implementation detail, not the
// authored/editable surface.
if !matches!(field.vis, Visibility::Public(_)) {
continue;
}
let attrs = parse_field_attrs(field);
if attrs.skip {
continue;
}
// For named structs the field name + accessor is the ident; for tuple
// structs the name is the index as a string and the accessor is the
// syn::Index token (which renders as `0`, `1`, ...).
let (field_name, accessor) = match &field.ident {
Some(ident) => (ident.to_string(), quote!(#ident)),
None => {
let idx = syn::Index::from(index);
(index.to_string(), quote!(#idx))
}
};
let ty = &field.ty;
// Syntactic type text, e.g. "f32", "bool", "Vec3", "Handle < Font >".
// The inspector dispatches a widget on this; unknown types fall back to
// a raw RON editor.
let type_name = quote!(#ty).to_string();
let range_tokens = match attrs.range {
Some((min, max)) => quote! {
::core::option::Option::Some((#min, #max))
},
None => quote! { ::core::option::Option::None },
};
infos.push(quote! {
::oxide_engine::reflect::FieldInfo {
name: #field_name,
type_name: #type_name,
range: #range_tokens,
}
});
get_arms.push(quote! {
#field_name => ::oxide_engine::reflect::__reflect_to_ron(&self.#accessor),
});
set_arms.push(quote! {
#field_name => {
self.#accessor = ::oxide_engine::reflect::__reflect_from_ron(#field_name, value)?;
::core::result::Result::Ok(())
}
});
}
let field_count = infos.len();
quote! {
impl #impl_generics ::oxide_engine::reflect::Reflect for #name #ty_generics #where_clause {
fn fields(&self) -> &'static [::oxide_engine::reflect::FieldInfo] {
static FIELDS: [::oxide_engine::reflect::FieldInfo; #field_count] = [
#(#infos),*
];
&FIELDS
}
fn get_field(&self, name: &str) -> ::core::option::Option<::std::string::String> {
match name {
#(#get_arms)*
_ => ::core::option::Option::None,
}
}
fn set_field(
&mut self,
name: &str,
value: &str,
) -> ::core::result::Result<(), ::oxide_engine::reflect::ReflectError> {
match name {
#(#set_arms)*
_ => ::core::result::Result::Err(
::oxide_engine::reflect::ReflectError::UnknownField(
::std::string::ToString::to_string(name),
),
),
}
}
}
}
.into()
}
/// Parsed `#[reflect(...)]` attributes on a single field.
#[derive(Default)]
struct FieldAttrs {
/// `#[reflect(skip)]` — exclude this public field from reflection.
skip: bool,
/// `#[reflect(min = X, max = Y)]` — numeric bounds passed to inspector
/// widgets so a normalized `f32` field becomes a slider instead of a drag.
/// Both must be present for a range to be recorded.
range: Option<(f32, f32)>,
}
fn parse_field_attrs(field: &syn::Field) -> FieldAttrs {
let mut out = FieldAttrs::default();
let mut min: Option<f32> = None;
let mut max: Option<f32> = None;
for attr in &field.attrs {
if !attr.path().is_ident("reflect") {
continue;
}
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("skip") {
out.skip = true;
} else if meta.path.is_ident("min") {
let lit: syn::LitFloat = meta.value()?.parse()?;
min = Some(lit.base10_parse::<f32>()?);
} else if meta.path.is_ident("max") {
let lit: syn::LitFloat = meta.value()?.parse()?;
max = Some(lit.base10_parse::<f32>()?);
}
Ok(())
});
}
if let (Some(mn), Some(mx)) = (min, max) {
out.range = Some((mn, mx));
}
out
}
/// Derives `oxide_engine::reflect::ReflectEnum` for a fieldless (C-like) enum,
/// exposing its variant names so a generic inspector can render a dropdown for
/// fields of that enum type.
///
/// Only **unit** variants are supported — a variant carrying data has no single
/// "pick from a list" representation. Variant names round-trip as RON (a unit
/// variant `Foo::Bar` serializes as `Bar`), which is exactly what `set_field`
/// consumes.
///
/// ```ignore
/// use oxide_engine::reflect::ReflectEnum;
///
/// #[derive(ReflectEnum, serde::Serialize, serde::Deserialize)]
/// enum Facing { North, East, South, West }
/// assert_eq!(Facing::variants(), &["North", "East", "South", "West"]);
/// ```
#[proc_macro_derive(ReflectEnum)]
pub fn derive_reflect_enum(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let data = match &input.data {
Data::Enum(data) => data,
_ => return compile_error(name, "ReflectEnum can only be derived for enums"),
};
let mut variant_names = Vec::new();
for variant in &data.variants {
if !matches!(variant.fields, Fields::Unit) {
return compile_error(
&variant.ident,
"ReflectEnum requires unit (fieldless) variants",
);
}
variant_names.push(variant.ident.to_string());
}
let count = variant_names.len();
quote! {
impl #impl_generics ::oxide_engine::reflect::ReflectEnum for #name #ty_generics #where_clause {
fn variants() -> &'static [&'static str] {
static VARIANTS: [&str; #count] = [ #(#variant_names),* ];
&VARIANTS
}
}
}
.into()
}
/// Emit a `compile_error!` at the derived type so the message is attributed
/// to the user's struct, not somewhere inside the generated impl.
fn compile_error(name: &syn::Ident, message: &str) -> TokenStream {
syn::Error::new(name.span(), message)
.to_compile_error()
.into()
}
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "oxide-engine"
description = "Oxide 3D game engine — core library"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
glam.workspace = true
hecs.workspace = true
winit.workspace = true
wgpu.workspace = true
pollster.workspace = true
bytemuck.workspace = true
gltf.workspace = true
ab_glyph.workspace = true
log.workspace = true
anyhow.workspace = true
thiserror.workspace = true
serde.workspace = true
ron.workspace = true
notify.workspace = true
oxide-engine-derive = { path = "../engine-derive" }
[dev-dependencies]
env_logger.workspace = true
criterion.workspace = true
[[bench]]
name = "transform"
harness = false
[[bench]]
name = "scene"
harness = false
[[bench]]
name = "app"
harness = false
+31
View File
@@ -0,0 +1,31 @@
//! Benchmark for the Stage 5 schedule/module overhead.
//!
//! Stage 5 criterion: module/system scheduling overhead must be negligible
//! compared to the Stage-4 hardcoded loop. There is no per-frame work here — the
//! benchmark measures the *frame overhead itself*: advancing timing, walking the
//! phase lists, and the fixed-timestep accumulator, with a realistic handful of
//! empty systems registered. Check that `app_empty_update` is in the low
//! nanoseconds (i.e. lost in the noise next to any real system's work).
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use oxide_engine::app::{App, Schedule};
fn empty_update(c: &mut Criterion) {
let mut app = App::new();
// A few no-op systems spread across phases, as a trivial game might have.
for _ in 0..4 {
app.add_system(Schedule::Update, |_| {});
}
app.add_system(Schedule::FixedUpdate, |_| {});
app.add_system(Schedule::Render, |_| {});
c.bench_function("app_empty_update", |bencher| {
bencher.iter(|| {
app.update(black_box(1.0 / 60.0));
black_box(app.time.frame)
});
});
}
criterion_group!(benches, empty_update);
criterion_main!(benches);
+58
View File
@@ -0,0 +1,58 @@
//! Benchmark for scene world-transform resolution.
//!
//! Stage 3 test criterion: a 10,000-entity scene with a 5-level-deep hierarchy
//! must resolve all world transforms in under 1ms. The `world_transforms_10k`
//! benchmark builds exactly that scene and measures a full bulk resolve; check
//! its reported time against the 1ms budget.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use oxide_engine::math::{Transform, Vec3};
use oxide_engine::scene::{Entity, Scene};
/// Builds a scene of `total` entities arranged as a `depth`-level hierarchy.
///
/// Level 0 holds the roots; each subsequent level's entities are distributed as
/// children of the previous level, so the tree is `depth` levels deep and the
/// node count is exactly `total`.
fn build_scene(total: usize, depth: usize) -> Scene {
let mut scene = Scene::new();
let per_level = total / depth;
let mut previous: Vec<Entity> = Vec::new();
for level in 0..depth {
// The last level absorbs any remainder so the count is exact.
let count = if level == depth - 1 {
total - per_level * (depth - 1)
} else {
per_level
};
let mut current = Vec::with_capacity(count);
for i in 0..count {
let t = Transform::from_translation(Vec3::new(0.01 * i as f32, 0.02, 0.03));
let entity = if previous.is_empty() {
scene.spawn("n", t)
} else {
// Spread children across the previous level round-robin.
scene.spawn_child(previous[i % previous.len()], "n", t)
};
current.push(entity);
}
previous = current;
}
scene
}
fn world_transforms_10k(c: &mut Criterion) {
let scene = build_scene(10_000, 5);
assert_eq!(scene.len(), 10_000);
c.bench_function("world_transforms_10k_depth5", |bencher| {
bencher.iter(|| {
let resolved = scene.world_transforms();
black_box(resolved.len())
});
});
}
criterion_group!(benches, world_transforms_10k);
criterion_main!(benches);
+57
View File
@@ -0,0 +1,57 @@
//! Benchmark for transform composition.
//!
//! Stage 1 test criterion: 1M transform multiplications must complete under
//! 10ms. The `compose_1m` benchmark below measures exactly that workload; check
//! its reported time against the 10ms budget.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use oxide_engine::math::{Quat, Transform, Vec3};
fn compose_1m(c: &mut Criterion) {
// A representative non-trivial transform (uniform scale → exact fast path).
let a = Transform::from_trs(
Vec3::new(1.0, 2.0, 3.0),
Quat::from_euler(glam::EulerRot::XYZ, 0.3, 0.5, 0.7),
Vec3::splat(1.5),
);
let b = Transform::from_trs(
Vec3::new(-2.0, 0.5, 4.0),
Quat::from_rotation_y(0.9),
Vec3::splat(0.8),
);
c.bench_function("compose_1m", |bencher| {
bencher.iter(|| {
// Compose 1M times. Inputs are re-fetched through `black_box` each
// iteration so the optimizer can neither hoist the call nor let the
// accumulated values blow up to infinity; the product is consumed.
let mut acc = Vec3::ZERO;
for _ in 0..1_000_000 {
let product = black_box(a).mul_transform(&black_box(b));
acc += product.translation;
}
black_box(acc)
});
});
}
fn point_transform_1m(c: &mut Criterion) {
let t = Transform::from_trs(
Vec3::new(1.0, 2.0, 3.0),
Quat::from_rotation_z(0.6),
Vec3::splat(2.0),
);
c.bench_function("transform_point_1m", |bencher| {
bencher.iter(|| {
let mut acc = Vec3::ZERO;
for i in 0..1_000_000u32 {
let p = Vec3::splat(i as f32 * 1e-6);
acc += t.transform_point(black_box(p));
}
black_box(acc)
});
});
}
criterion_group!(benches, compose_1m, point_transform_1m);
criterion_main!(benches);
+503
View File
@@ -0,0 +1,503 @@
//! The application core: an [`App`] assembled by registering [`Module`]s.
//!
//! Stage 5 ties the core framework together. An `App` owns the shared engine
//! state — the [`Scene`], the [`AssetServer`], the [`TypeRegistry`], the
//! [`LayerRegistry`], frame [`Time`], and arbitrary user resources — plus a
//! [`Schedule`] of systems. Functionality is added by **modules**: each
//! [`Module::build`] registers systems, component types, asset loaders, and
//! resources, so the engine is composed rather than hard-wired and an exported
//! game compiles in only the modules it uses.
//!
//! ```
//! use oxide_engine::app::{App, DefaultModules};
//!
//! let mut app = App::new();
//! app.add_modules(DefaultModules);
//! app.update(1.0 / 60.0); // advance one frame
//! ```
mod module;
mod schedule;
pub use module::{CoreModule, DefaultModules, Module, RenderModule};
pub use schedule::Schedule;
use std::any::{Any, TypeId};
use std::collections::{BTreeMap, HashMap};
use schedule::{run_phase, SystemEntry, Systems};
use crate::asset::{AssetLoader, AssetServer};
use crate::layer::LayerRegistry;
use crate::reflect::TypeRegistry;
use crate::scene::Scene;
/// The default fixed-timestep duration (60 Hz) for [`Schedule::FixedUpdate`].
pub const DEFAULT_FIXED_TIMESTEP: f32 = 1.0 / 60.0;
/// An upper bound on fixed steps per frame, so a long stall (e.g. a breakpoint)
/// cannot trigger an unbounded catch-up "spiral of death".
const MAX_FIXED_STEPS_PER_FRAME: u32 = 8;
/// Per-frame timing, refreshed by [`App::update`] and readable by systems.
#[derive(Debug, Clone, Copy)]
pub struct Time {
/// Seconds elapsed since the previous frame.
pub delta: f32,
/// Seconds elapsed since the app started.
pub elapsed: f32,
/// The fixed-timestep duration used by [`Schedule::FixedUpdate`].
pub fixed_delta: f32,
/// Frames advanced so far.
pub frame: u64,
}
impl Default for Time {
fn default() -> Self {
Self {
delta: 0.0,
elapsed: 0.0,
fixed_delta: DEFAULT_FIXED_TIMESTEP,
frame: 0,
}
}
}
/// The application core. See the [module docs](self).
pub struct App {
/// The active scene graph.
pub scene: Scene,
/// The shared asset server (built-in loaders registered).
pub assets: AssetServer,
/// The reflection/type registry for dual-editable components.
pub types: TypeRegistry,
/// The project's named layers.
pub layers: LayerRegistry,
/// Per-frame timing.
pub time: Time,
resources: HashMap<TypeId, Box<dyn Any>>,
systems: Systems,
/// Registered modules → enabled flag.
modules: BTreeMap<&'static str, bool>,
/// The module currently being built, so registrations can be attributed.
current_module: Option<&'static str>,
/// Per-module bookkeeping for clean removal.
module_types: HashMap<&'static str, Vec<&'static str>>,
module_loaders: HashMap<&'static str, Vec<String>>,
module_resources: HashMap<&'static str, Vec<TypeId>>,
fixed_accumulator: f32,
}
impl App {
/// A new app with empty core state and no modules. The [`AssetServer`] comes
/// with the engine's built-in loaders already registered.
pub fn new() -> Self {
Self {
scene: Scene::new(),
assets: AssetServer::new(),
types: TypeRegistry::new(),
layers: LayerRegistry::new(),
time: Time::default(),
resources: HashMap::new(),
systems: Systems::default(),
modules: BTreeMap::new(),
current_module: None,
module_types: HashMap::new(),
module_loaders: HashMap::new(),
module_resources: HashMap::new(),
fixed_accumulator: 0.0,
}
}
// --- Modules -----------------------------------------------------------
/// Adds a module, running its [`Module::build`] and attributing everything
/// it registers to it.
///
/// # Panics
/// Panics if a module with the same [`name`](Module::name) is already added.
pub fn add_module<M: Module>(&mut self, module: M) -> &mut Self {
let name = module.name();
assert!(
!self.modules.contains_key(name),
"module '{name}' is already added"
);
self.modules.insert(name, true);
let previous = self.current_module.replace(name);
module.build(self);
self.current_module = previous;
self
}
/// Adds a bundle of modules (e.g. [`DefaultModules`]).
pub fn add_modules<B: ModuleBundle>(&mut self, bundle: B) -> &mut Self {
bundle.add_to(self);
self
}
/// Whether a module is registered.
pub fn has_module(&self, name: &str) -> bool {
self.modules.contains_key(name)
}
/// The registered module names, sorted.
pub fn modules(&self) -> impl Iterator<Item = &'static str> + '_ {
self.modules.keys().copied()
}
/// Whether a registered module is enabled. Unknown modules report `false`.
pub fn is_module_enabled(&self, name: &str) -> bool {
self.modules.get(name).copied().unwrap_or(false)
}
/// Enables or disables a module's systems without removing them. Disabled
/// modules' systems are skipped each frame. Returns whether the module exists.
pub fn set_module_enabled(&mut self, name: &str, enabled: bool) -> bool {
match self.modules.get_mut(name) {
Some(flag) => {
*flag = enabled;
true
}
None => false,
}
}
/// Removes a module and everything it contributed — systems, registered
/// component types, asset loaders, and resources — leaving no dangling
/// references. Returns whether the module existed.
pub fn remove_module(&mut self, name: &str) -> bool {
if self.modules.remove(name).is_none() {
return false;
}
self.systems.remove_module(name);
for type_name in self.module_types.remove(name).unwrap_or_default() {
self.types.unregister(type_name);
}
for ext in self.module_loaders.remove(name).unwrap_or_default() {
self.assets.unregister_loader(&ext);
}
for type_id in self.module_resources.remove(name).unwrap_or_default() {
self.resources.remove(&type_id);
}
true
}
/// Whether a system contributed by `module` should run this frame: systems
/// with no owning module always run; module-owned systems run only while
/// their module is enabled.
pub(crate) fn is_system_enabled(&self, module: Option<&'static str>) -> bool {
match module {
None => true,
Some(name) => self.is_module_enabled(name),
}
}
// --- Registration (attributed to the current module) -------------------
/// Adds a system to a schedule phase. Systems run in phase order, then in
/// registration order within a phase.
pub fn add_system(
&mut self,
phase: Schedule,
system: impl FnMut(&mut App) + 'static,
) -> &mut Self {
self.systems.push(
phase,
SystemEntry {
module: self.current_module,
run: Box::new(system),
},
);
self
}
/// Registers a reflected component type under `name` (see [`TypeRegistry`]).
pub fn register_type<T>(&mut self, name: &'static str) -> &mut Self
where
T: hecs::Component + serde::Serialize + serde::de::DeserializeOwned,
{
self.types.register::<T>(name);
if let Some(module) = self.current_module {
self.module_types.entry(module).or_default().push(name);
}
self
}
/// Registers an asset loader (see [`AssetServer::register_loader`]).
pub fn add_loader<L: AssetLoader>(&mut self, loader: L) -> &mut Self {
if let Some(module) = self.current_module {
let exts = loader.extensions().iter().map(|e| e.to_lowercase());
self.module_loaders.entry(module).or_default().extend(exts);
}
self.assets.register_loader(loader);
self
}
// --- Resources ---------------------------------------------------------
/// Inserts (or replaces) a shared resource of type `T`.
pub fn insert_resource<T: 'static>(&mut self, value: T) -> &mut Self {
let id = TypeId::of::<T>();
if let Some(module) = self.current_module {
self.module_resources.entry(module).or_default().push(id);
}
self.resources.insert(id, Box::new(value));
self
}
/// Borrows a resource of type `T`, or `None` if absent.
pub fn get_resource<T: 'static>(&self) -> Option<&T> {
self.resources
.get(&TypeId::of::<T>())
.and_then(|b| b.downcast_ref::<T>())
}
/// Mutably borrows a resource of type `T`, or `None` if absent.
pub fn get_resource_mut<T: 'static>(&mut self) -> Option<&mut T> {
self.resources
.get_mut(&TypeId::of::<T>())
.and_then(|b| b.downcast_mut::<T>())
}
/// Removes and returns the resource of type `T`, or `None` if absent.
///
/// Lets a system take exclusive ownership of a resource for the duration of
/// a call — e.g. the physics step takes the `PhysicsWorld` out so it can
/// borrow the [`Scene`] mutably at the same time — then re-inserts it.
pub fn remove_resource<T: 'static>(&mut self) -> Option<T> {
self.resources
.remove(&TypeId::of::<T>())
.and_then(|b| b.downcast::<T>().ok())
.map(|b| *b)
}
/// Whether a resource of type `T` is present.
pub fn has_resource<T: 'static>(&self) -> bool {
self.resources.contains_key(&TypeId::of::<T>())
}
// --- Running -----------------------------------------------------------
/// Sets the fixed-timestep duration used by [`Schedule::FixedUpdate`].
pub fn set_fixed_timestep(&mut self, seconds: f32) -> &mut Self {
assert!(seconds > 0.0, "fixed timestep must be positive");
self.time.fixed_delta = seconds;
self
}
/// The number of systems registered across all phases.
pub fn system_count(&self) -> usize {
self.systems.total()
}
/// Advances one frame by `delta` seconds: runs the per-frame phases once and
/// [`FixedUpdate`](Schedule::FixedUpdate) as many whole fixed steps as the
/// accumulated time allows (capped to avoid a catch-up spiral).
pub fn update(&mut self, delta: f32) {
self.time.delta = delta;
self.time.elapsed += delta;
self.time.frame += 1;
// How many fixed steps to run this frame.
self.fixed_accumulator += delta;
let mut steps = (self.fixed_accumulator / self.time.fixed_delta) as u32;
if steps > MAX_FIXED_STEPS_PER_FRAME {
steps = MAX_FIXED_STEPS_PER_FRAME;
self.fixed_accumulator = 0.0;
} else {
self.fixed_accumulator -= steps as f32 * self.time.fixed_delta;
}
self.run_frame(steps);
}
/// Advances **exactly one fixed timestep**: bumps frame time by
/// [`fixed_delta`](Time::fixed_delta) and runs the per-frame phases once with
/// a single [`FixedUpdate`](Schedule::FixedUpdate), bypassing the
/// accumulator. This is the editor play-mode **Step** primitive — single-step
/// the simulation while paused — and yields one deterministic tick.
pub fn step(&mut self) {
let dt = self.time.fixed_delta;
self.time.delta = dt;
self.time.elapsed += dt;
self.time.frame += 1;
self.run_frame(1);
}
/// Runs the per-frame phases once with `fixed_steps` runs of
/// [`FixedUpdate`](Schedule::FixedUpdate). The systems are moved out first so
/// each gets exclusive `&mut App`, then anything registered mid-frame is
/// folded back. Shared by [`update`](Self::update) and [`step`](Self::step).
fn run_frame(&mut self, fixed_steps: u32) {
let mut systems = std::mem::take(&mut self.systems);
run_phase(&mut systems, self, Schedule::First);
run_phase(&mut systems, self, Schedule::Input);
run_phase(&mut systems, self, Schedule::PreUpdate);
for _ in 0..fixed_steps {
run_phase(&mut systems, self, Schedule::FixedUpdate);
}
run_phase(&mut systems, self, Schedule::Update);
run_phase(&mut systems, self, Schedule::PostUpdate);
run_phase(&mut systems, self, Schedule::Render);
run_phase(&mut systems, self, Schedule::Last);
// Fold back anything registered during the frame, then restore.
systems.merge(std::mem::take(&mut self.systems));
self.systems = systems;
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
/// A group of modules added together. Implemented for [`DefaultModules`] and for
/// tuples, so `app.add_modules((ModuleA, ModuleB))` works.
pub trait ModuleBundle {
/// Adds every module in the bundle to `app`.
fn add_to(self, app: &mut App);
}
impl<A: Module> ModuleBundle for (A,) {
fn add_to(self, app: &mut App) {
app.add_module(self.0);
}
}
impl<A: Module, B: Module> ModuleBundle for (A, B) {
fn add_to(self, app: &mut App) {
app.add_module(self.0);
app.add_module(self.1);
}
}
impl<A: Module, B: Module, C: Module> ModuleBundle for (A, B, C) {
fn add_to(self, app: &mut App) {
app.add_module(self.0);
app.add_module(self.1);
app.add_module(self.2);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::{Transform, Vec3};
use std::cell::Cell;
use std::rc::Rc;
#[test]
fn empty_app_updates_and_advances_time() {
let mut app = App::new();
assert_eq!(app.time.frame, 0);
app.update(0.5);
assert_eq!(app.time.frame, 1);
assert!((app.time.elapsed - 0.5).abs() < 1e-6);
assert!((app.time.delta - 0.5).abs() < 1e-6);
}
#[test]
fn systems_run_in_phase_then_registration_order() {
let log = Rc::new(std::cell::RefCell::new(Vec::new()));
let mut app = App::new();
let l = log.clone();
app.add_system(Schedule::Update, move |_| l.borrow_mut().push("update-1"));
let l = log.clone();
app.add_system(Schedule::Update, move |_| l.borrow_mut().push("update-2"));
let l = log.clone();
app.add_system(Schedule::First, move |_| l.borrow_mut().push("first"));
app.update(0.0);
assert_eq!(*log.borrow(), vec!["first", "update-1", "update-2"]);
}
#[test]
fn fixed_update_runs_by_accumulated_time() {
let count = Rc::new(Cell::new(0u32));
let mut app = App::new();
app.set_fixed_timestep(0.1);
let c = count.clone();
app.add_system(Schedule::FixedUpdate, move |_| c.set(c.get() + 1));
app.update(0.25); // 0.25 / 0.1 = 2 whole steps, ~0.05 left over
assert_eq!(count.get(), 2);
// 0.05 carried + 0.06 = 0.11 -> 1 more step (kept off the exact float
// boundary so the result is robust to f32 rounding).
app.update(0.06);
assert_eq!(count.get(), 3);
}
#[test]
fn fixed_update_is_capped_against_spiral() {
let count = Rc::new(Cell::new(0u32));
let mut app = App::new();
app.set_fixed_timestep(0.001);
let c = count.clone();
app.add_system(Schedule::FixedUpdate, move |_| c.set(c.get() + 1));
app.update(10.0); // would be 10000 steps; capped
assert_eq!(count.get(), MAX_FIXED_STEPS_PER_FRAME);
}
#[test]
fn step_runs_one_fixed_tick_and_the_per_frame_phases_once() {
let fixed = Rc::new(Cell::new(0u32));
let update = Rc::new(Cell::new(0u32));
let mut app = App::new();
app.set_fixed_timestep(0.1);
let f = fixed.clone();
app.add_system(Schedule::FixedUpdate, move |_| f.set(f.get() + 1));
let u = update.clone();
app.add_system(Schedule::Update, move |_| u.set(u.get() + 1));
app.step();
// Exactly one fixed step and one Update, regardless of accumulator.
assert_eq!(fixed.get(), 1);
assert_eq!(update.get(), 1);
assert_eq!(app.time.frame, 1);
assert!((app.time.elapsed - 0.1).abs() < 1e-6);
assert!((app.time.delta - 0.1).abs() < 1e-6);
// A second step advances exactly one more, deterministically.
app.step();
assert_eq!(fixed.get(), 2);
assert_eq!(update.get(), 2);
}
#[test]
fn resources_round_trip() {
let mut app = App::new();
app.insert_resource(42u32);
assert_eq!(app.get_resource::<u32>(), Some(&42));
*app.get_resource_mut::<u32>().unwrap() += 1;
assert_eq!(app.get_resource::<u32>(), Some(&43));
assert!(app.get_resource::<String>().is_none());
// remove_resource takes ownership and clears the slot.
assert_eq!(app.remove_resource::<u32>(), Some(43));
assert!(!app.has_resource::<u32>());
assert_eq!(app.remove_resource::<u32>(), None);
}
#[test]
fn a_system_can_mutate_the_scene_each_frame() {
let mut app = App::new();
app.scene.spawn("a", Transform::IDENTITY);
// Each Update, nudge every entity's transform.
app.add_system(Schedule::Update, |app| {
let entities: Vec<_> = app.scene.entities().collect();
for e in entities {
if let Some(mut t) = app.scene.get_mut::<Transform>(e) {
t.translation += Vec3::X;
}
}
});
app.update(0.0);
app.update(0.0);
let e = app.scene.entities().next().unwrap();
assert!((app.scene.local_transform(e).unwrap().translation.x - 2.0).abs() < 1e-6);
}
}
+165
View File
@@ -0,0 +1,165 @@
//! The [`Module`] trait and the engine's built-in modules.
//!
//! A module is the unit of engine extension: it bundles systems, component
//! types, asset loaders, and resources behind one documented entry point, so
//! anyone — including AI agents — can add a capability by writing a module, and
//! an exported game compiles in only the modules it registers. The editor
//! integration half of the trait arrives in Stage 6.
use super::{App, ModuleBundle, Schedule};
use crate::layer::{Layer, Tags};
use crate::math::Transform;
use crate::render::MeshRenderer;
use crate::scene::Node;
/// A self-contained unit of engine functionality.
///
/// Implement [`build`](Self::build) to register everything the module provides
/// via the [`App`] facade ([`add_system`](App::add_system),
/// [`register_type`](App::register_type), [`add_loader`](App::add_loader),
/// [`insert_resource`](App::insert_resource)). Everything registered during
/// `build` is attributed to the module, so it can be enabled, disabled, or
/// removed as a unit.
pub trait Module: 'static {
/// A stable, unique name (used to enable/disable/remove the module and, in
/// later stages, to express dependencies).
fn name(&self) -> &'static str;
/// Registers the module's systems, types, loaders, and resources on `app`.
fn build(&self, app: &mut App);
}
/// The core module: registers the always-present scene component types for
/// reflection (dual-editability), so the editor and scripts can address them.
///
/// This is the runtime "wrapper" for the math/scene/layer building blocks that
/// already exist as plain library types — it does not add behavior, it exposes
/// those types through the [`TypeRegistry`](crate::reflect::TypeRegistry).
pub struct CoreModule;
impl Module for CoreModule {
fn name(&self) -> &'static str {
"core"
}
fn build(&self, app: &mut App) {
app.register_type::<Transform>("Transform");
app.register_type::<Node>("Node");
app.register_type::<Layer>("Layer");
app.register_type::<Tags>("Tags");
}
}
/// The render module: registers the renderable scene components for reflection.
///
/// The forward renderer itself is driven by the editor/host today; this module
/// is what makes [`MeshRenderer`] a first-class, dual-editable component. As the
/// data-driven render pipeline grows it will register its render-phase systems
/// here too.
pub struct RenderModule;
impl Module for RenderModule {
fn name(&self) -> &'static str {
"render"
}
fn build(&self, app: &mut App) {
app.register_type::<MeshRenderer>("MeshRenderer");
// Placeholder render-phase system so the phase is exercised; real passes
// land with the Stage 5 render pipeline piece.
app.add_system(Schedule::Render, |_app| {});
}
}
/// The engine's standard set of built-in modules, added with
/// [`App::add_modules`](super::App::add_modules).
///
/// ```
/// use oxide_engine::app::{App, DefaultModules};
/// let mut app = App::new();
/// app.add_modules(DefaultModules);
/// assert!(app.has_module("core") && app.has_module("render"));
/// ```
pub struct DefaultModules;
impl ModuleBundle for DefaultModules {
fn add_to(self, app: &mut App) {
app.add_module(CoreModule);
app.add_module(RenderModule);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::PrimitiveShape;
#[test]
fn default_modules_register_core_types() {
let mut app = App::new();
app.add_modules(DefaultModules);
assert!(app.has_module("core"));
assert!(app.has_module("render"));
assert!(app.types.is_registered("Transform"));
assert!(app.types.is_registered("MeshRenderer"));
assert_eq!(app.modules().collect::<Vec<_>>(), vec!["core", "render"]);
}
#[test]
fn removing_a_module_removes_its_contributions() {
let mut app = App::new();
app.add_modules(DefaultModules);
assert!(app.types.is_registered("MeshRenderer"));
let systems_before = app.system_count();
assert!(app.remove_module("render"));
// Its registered type is gone, its render system is gone, core remains.
assert!(!app.has_module("render"));
assert!(!app.types.is_registered("MeshRenderer"));
assert!(app.types.is_registered("Transform"));
assert!(app.system_count() < systems_before);
}
#[test]
fn disabling_a_module_skips_its_systems_without_removing() {
use std::cell::Cell;
use std::rc::Rc;
struct Ticker(Rc<Cell<u32>>);
impl Module for Ticker {
fn name(&self) -> &'static str {
"ticker"
}
fn build(&self, app: &mut App) {
let counter = self.0.clone();
app.add_system(Schedule::Update, move |_| counter.set(counter.get() + 1));
}
}
let count = Rc::new(Cell::new(0u32));
let mut app = App::new();
app.add_module(Ticker(count.clone()));
app.update(0.0);
assert_eq!(count.get(), 1);
app.set_module_enabled("ticker", false);
app.update(0.0); // skipped
assert_eq!(count.get(), 1);
app.set_module_enabled("ticker", true);
app.update(0.0); // runs again
assert_eq!(count.get(), 2);
}
#[test]
fn a_module_can_add_a_loader_removed_with_it() {
// A module registers MeshRenderer + uses a primitive, then is removed.
let mut app = App::new();
app.add_module(RenderModule);
// Sanity: the primitive enum the render component references is usable.
assert_eq!(PrimitiveShape::ALL.len(), 3);
assert!(app.remove_module("render"));
assert!(!app.types.is_registered("MeshRenderer"));
}
}
+146
View File
@@ -0,0 +1,146 @@
//! The system schedule: the ordered phases an [`App`](super::App) runs each
//! frame, and the per-phase lists of systems modules attach to.
use super::App;
/// The ordered phases of one frame.
///
/// Systems are attached to a phase and run in phase order; within a phase they
/// run in registration order, so behavior is fully deterministic. The phases
/// mirror a conventional game loop:
///
/// - [`First`](Self::First) — start-of-frame bookkeeping.
/// - [`Input`](Self::Input) — gather input (Stage 7).
/// - [`PreUpdate`](Self::PreUpdate) — engine work before game logic.
/// - [`FixedUpdate`](Self::FixedUpdate) — fixed-timestep work; runs **zero or
/// more** times per frame so simulation is frame-rate independent. Physics
/// (Stage 9) lives here.
/// - [`Update`](Self::Update) — per-frame game logic.
/// - [`PostUpdate`](Self::PostUpdate) — engine work after game logic.
/// - [`Render`](Self::Render) — drawing (Stage 5 pipeline onward).
/// - [`Last`](Self::Last) — end-of-frame cleanup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Schedule {
First,
Input,
PreUpdate,
FixedUpdate,
Update,
PostUpdate,
Render,
Last,
}
impl Schedule {
/// The once-per-frame phases, in order (everything except `FixedUpdate`,
/// which is driven separately by the fixed-timestep accumulator).
pub(crate) const PER_FRAME: [Schedule; 7] = [
Schedule::First,
Schedule::Input,
Schedule::PreUpdate,
Schedule::Update,
Schedule::PostUpdate,
Schedule::Render,
Schedule::Last,
];
}
/// One registered system: a closure plus the module that contributed it (so the
/// module can be disabled or removed).
pub(crate) struct SystemEntry {
pub(crate) module: Option<&'static str>,
pub(crate) run: Box<dyn FnMut(&mut App)>,
}
/// The collection of systems, grouped by phase, in registration order.
#[derive(Default)]
pub(crate) struct Systems {
first: Vec<SystemEntry>,
input: Vec<SystemEntry>,
pre_update: Vec<SystemEntry>,
fixed_update: Vec<SystemEntry>,
update: Vec<SystemEntry>,
post_update: Vec<SystemEntry>,
render: Vec<SystemEntry>,
last: Vec<SystemEntry>,
}
impl Systems {
fn phase_mut(&mut self, phase: Schedule) -> &mut Vec<SystemEntry> {
match phase {
Schedule::First => &mut self.first,
Schedule::Input => &mut self.input,
Schedule::PreUpdate => &mut self.pre_update,
Schedule::FixedUpdate => &mut self.fixed_update,
Schedule::Update => &mut self.update,
Schedule::PostUpdate => &mut self.post_update,
Schedule::Render => &mut self.render,
Schedule::Last => &mut self.last,
}
}
fn phase(&self, phase: Schedule) -> &[SystemEntry] {
match phase {
Schedule::First => &self.first,
Schedule::Input => &self.input,
Schedule::PreUpdate => &self.pre_update,
Schedule::FixedUpdate => &self.fixed_update,
Schedule::Update => &self.update,
Schedule::PostUpdate => &self.post_update,
Schedule::Render => &self.render,
Schedule::Last => &self.last,
}
}
pub(crate) fn push(&mut self, phase: Schedule, entry: SystemEntry) {
self.phase_mut(phase).push(entry);
}
pub(crate) fn total(&self) -> usize {
Schedule::PER_FRAME
.iter()
.chain(std::iter::once(&Schedule::FixedUpdate))
.map(|p| self.phase(*p).len())
.sum()
}
const ALL_PHASES: [Schedule; 8] = [
Schedule::First,
Schedule::Input,
Schedule::PreUpdate,
Schedule::FixedUpdate,
Schedule::Update,
Schedule::PostUpdate,
Schedule::Render,
Schedule::Last,
];
/// Drops every system contributed by `module`.
pub(crate) fn remove_module(&mut self, module: &str) {
for phase in Self::ALL_PHASES {
self.phase_mut(phase).retain(|e| e.module != Some(module));
}
}
/// Appends all of `other`'s systems (used to fold back systems registered
/// while the frame was running).
pub(crate) fn merge(&mut self, mut other: Systems) {
for phase in Self::ALL_PHASES {
let tail = std::mem::take(other.phase_mut(phase));
self.phase_mut(phase).extend(tail);
}
}
}
/// Runs one phase: every enabled system in registration order.
///
/// The [`Systems`] are moved out of the [`App`] before phases run (so systems
/// get exclusive `&mut App` access), so `systems` and `app` here are disjoint.
/// Systems from a disabled module are skipped without being removed.
pub(crate) fn run_phase(systems: &mut Systems, app: &mut App, phase: Schedule) {
for entry in systems.phase_mut(phase) {
if app.is_system_enabled(entry.module) {
(entry.run)(app);
}
}
}
+766
View File
@@ -0,0 +1,766 @@
//! [`AssetDatabase`]: stable, project-relative asset references.
//!
//! The [`AssetServer`](super::AssetServer) loads assets by *path*, but a scene
//! or UI document must not bake **absolute** system paths into its saved data —
//! that would break the moment the project is moved to another machine or
//! directory, and it is the chief obstacle to a clean game export (Stage 16).
//!
//! The asset database is the bridge. It assigns every imported asset a stable
//! [`AssetUid`] and records, per project, the mapping
//! **`AssetUid` ↔ assets-relative path** (e.g. `"fonts/Inter-Regular.ttf"`).
//! Saved documents reference assets by `AssetUid`; resolving a uid yields the
//! relative path, which combined with the (possibly new) project root gives an
//! absolute path the [`AssetServer`](super::AssetServer) loads and deduplicates.
//! Because the stored mapping is purely relative, a reference resolves to the
//! same [`Handle`] across save/load **and** after the whole project directory
//! moves.
//!
//! The uid layer (rather than referencing by relative path directly) means an
//! asset can later be *renamed or moved within* the project without breaking
//! references — the uid travels with the file in the manifest.
//!
//! Assets live under typed subfolders of the project's `assets/` directory
//! ([`AssetKind`] → folder), so the database (and the editor's browser) can
//! present and filter them by type without inspecting file contents.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{AssetServer, Handle};
use crate::project::ASSETS_DIR;
/// The manifest file (RON) at the project root recording the uid ↔ path map.
///
/// It sits at the root rather than inside `assets/` so a scan of the typed
/// asset folders never treats the manifest itself as an asset.
pub const ASSET_MANIFEST_FILE: &str = "assets.manifest";
/// The typed category of a project asset.
///
/// A kind fixes the asset's subfolder under `assets/` and the file extensions
/// that belong to it, letting the database classify files by where they live
/// (with extension as a fallback for files dropped directly in `assets/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetKind {
/// Text rendering fonts (`fonts/`): `.ttf`, `.otf`.
Font,
/// Images and textures (`textures/`): `.png`, `.jpg`, …
Texture,
/// 3D models (`models/`): `.gltf`, `.glb`, `.obj`.
Model,
/// Sound and music (`audio/`): `.wav`, `.ogg`, …
Audio,
/// Serialized UI documents (`ui/`).
Ui,
/// Game-logic scripts (`scripts/`): `.rhai` (Stage 10).
Script,
/// Anything that does not fall into a known typed folder or extension.
Other,
}
impl AssetKind {
/// The typed kinds in their canonical order (excludes [`Other`](Self::Other),
/// which has no folder of its own).
pub const TYPED: [AssetKind; 6] = [
AssetKind::Font,
AssetKind::Texture,
AssetKind::Model,
AssetKind::Audio,
AssetKind::Ui,
AssetKind::Script,
];
/// The subfolder name under `assets/` for this kind (empty for
/// [`Other`](Self::Other), which has no dedicated folder).
pub fn folder(self) -> &'static str {
match self {
AssetKind::Font => "fonts",
AssetKind::Texture => "textures",
AssetKind::Model => "models",
AssetKind::Audio => "audio",
AssetKind::Ui => "ui",
AssetKind::Script => "scripts",
AssetKind::Other => "",
}
}
/// The lower-case file extensions (without the dot) that belong to this
/// kind. [`Other`](Self::Other) claims none.
pub fn extensions(self) -> &'static [&'static str] {
match self {
AssetKind::Font => &["ttf", "otf"],
AssetKind::Texture => &["png", "jpg", "jpeg", "tga", "bmp", "dds", "ktx2"],
AssetKind::Model => &["gltf", "glb", "obj"],
AssetKind::Audio => &["wav", "ogg", "mp3", "flac"],
// UI documents share the `.ron` extension with scenes, so a UI asset
// is recognised by its `ui/` folder rather than its extension.
AssetKind::Ui => &[],
AssetKind::Script => &["rhai"],
AssetKind::Other => &[],
}
}
/// The kind owning the typed `folder` name, if any.
pub fn from_folder(folder: &str) -> Option<AssetKind> {
AssetKind::TYPED.into_iter().find(|k| k.folder() == folder)
}
/// The kind that claims `extension` (without the dot, any case), if any.
pub fn from_extension(extension: &str) -> Option<AssetKind> {
let ext = extension.to_lowercase();
AssetKind::TYPED
.into_iter()
.find(|k| k.extensions().contains(&ext.as_str()))
}
/// Classifies an assets-relative path. The leading folder wins (so a file in
/// `ui/` is [`Ui`](Self::Ui) regardless of extension); files outside a typed
/// folder fall back to their extension, else [`Other`](Self::Other).
pub fn classify(relative_path: &str) -> AssetKind {
if let Some((head, _)) = relative_path.split_once('/') {
if let Some(kind) = AssetKind::from_folder(head) {
return kind;
}
}
Path::new(relative_path)
.extension()
.and_then(|e| e.to_str())
.and_then(AssetKind::from_extension)
.unwrap_or(AssetKind::Other)
}
/// The kind an asset reference of target type `target` refers to, used to
/// filter an asset picker. `target` is the inner type of an `AssetRef<T>`
/// (or `Handle<T>`) field (see [`asset_ref_target`]); unknown targets yield
/// `None` so the picker can offer every kind.
pub fn for_handle_target(target: &str) -> Option<AssetKind> {
match target {
"Font" | "UiFont" => Some(AssetKind::Font),
"GltfModel" | "Model" | "Mesh" => Some(AssetKind::Model),
"Texture" | "Image" => Some(AssetKind::Texture),
"AudioClip" | "Sound" | "Audio" => Some(AssetKind::Audio),
"UiPanel" | "UiDocument" => Some(AssetKind::Ui),
"ScriptAsset" | "Script" => Some(AssetKind::Script),
_ => None,
}
}
}
/// If `type_name` is an asset-reference field spelling — [`AssetRef<T>`] (the
/// serializable reference components store) or a bare [`Handle<T>`] — returns
/// the inner target type's short name; otherwise `None`.
///
/// Reflection records a field's *syntactic* type name (see
/// [`FieldInfo::type_name`](crate::reflect::FieldInfo::type_name)), which for an
/// asset-reference field is something like `"AssetRef < Font >"` or
/// `"Handle<crate::ui::Font>"`. This normalizes whitespace, unwraps the single
/// generic argument, and strips any module path, yielding e.g. `"Font"`. The
/// editor uses it to recognise such fields and pick the right asset filter via
/// [`AssetKind::for_handle_target`].
pub fn asset_ref_target(type_name: &str) -> Option<&str> {
// Peel the wrapper structurally, trimming whitespace at each step, so both
// `"AssetRef < Font >"` and `"AssetRef<Font>"` parse and we return a borrow
// of the original string.
let t = type_name.trim();
let inner = t
.strip_prefix("AssetRef")
.or_else(|| t.strip_prefix("Handle"))?
.trim_start();
let inner = inner.strip_prefix('<')?.trim();
let inner = inner.strip_suffix('>')?.trim();
// Reject nested generics / multiple args we don't understand.
if inner.contains('<') || inner.contains(',') {
return None;
}
// Strip any module path (`crate::ui::Font` -> `Font`).
Some(inner.rsplit("::").next().unwrap_or(inner).trim())
}
/// A stable, per-project identifier for one asset.
///
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
/// every run — an `AssetUid` is persisted in the project's manifest and stays
/// attached to its asset across sessions, so saved references keep resolving.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AssetUid(pub u64);
impl AssetUid {
/// The raw numeric value.
pub fn value(self) -> u64 {
self.0
}
}
/// A typed, serializable reference to a project asset.
///
/// This is what a **component** stores when it points at an asset (a UI label's
/// font, a renderer's mesh, …). A live [`Handle<T>`] is not serializable and is
/// tied to one process run, so persisting it would be wrong; an `AssetRef<T>`
/// instead holds the stable [`AssetUid`] and resolves to a handle on demand via
/// [`resolve`](Self::resolve) (database → relative path → server → handle).
///
/// Being a thin wrapper over `Option<AssetUid>`, it serializes compactly and
/// round-trips through reflection's RON path, so an asset-reference field is
/// editable in the inspector with no per-type code. The phantom `T` records the
/// target asset type, which the editor reads from the field's spelling
/// (`"AssetRef < Font >"`) via [`asset_ref_target`] to filter the picker.
pub struct AssetRef<T> {
uid: Option<AssetUid>,
_marker: std::marker::PhantomData<fn() -> T>,
}
impl<T> AssetRef<T> {
/// An empty reference, pointing at no asset.
pub const fn none() -> Self {
Self {
uid: None,
_marker: std::marker::PhantomData,
}
}
/// A reference to the asset with stable id `uid`.
pub const fn new(uid: AssetUid) -> Self {
Self {
uid: Some(uid),
_marker: std::marker::PhantomData,
}
}
/// The referenced asset's stable id, or `None` if empty.
pub fn uid(self) -> Option<AssetUid> {
self.uid
}
/// Whether this reference points at an asset.
pub fn is_some(self) -> bool {
self.uid.is_some()
}
/// Points the reference at `uid` (or clears it with `None`).
pub fn set(&mut self, uid: Option<AssetUid>) {
self.uid = uid;
}
/// Resolves to a loaded [`Handle<T>`] via `db` + `server`, or `None` if the
/// reference is empty or its uid is unknown to the database.
pub fn resolve(self, db: &AssetDatabase, server: &AssetServer) -> Option<Handle<T>>
where
T: Send + Sync + 'static,
{
db.load::<T>(server, self.uid?)
}
}
// Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default`
// etc., but an `AssetRef<T>` carries no `T` value — only a uid + phantom marker.
impl<T> Clone for AssetRef<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for AssetRef<T> {}
impl<T> Default for AssetRef<T> {
fn default() -> Self {
Self::none()
}
}
impl<T> PartialEq for AssetRef<T> {
fn eq(&self, other: &Self) -> bool {
self.uid == other.uid
}
}
impl<T> Eq for AssetRef<T> {}
impl<T> std::fmt::Debug for AssetRef<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("AssetRef").field(&self.uid).finish()
}
}
// Serialize transparently as the inner `Option<AssetUid>` so saved data is just
// the uid (or unit `None`) and stays independent of `T`.
impl<T> Serialize for AssetRef<T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.uid.serialize(serializer)
}
}
impl<'de, T> Deserialize<'de> for AssetRef<T> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self {
uid: Option::<AssetUid>::deserialize(deserializer)?,
_marker: std::marker::PhantomData,
})
}
}
/// One asset's record in the database: its stable id, kind, and the path it
/// lives at *relative to the project's `assets/` directory* (forward slashes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntry {
/// The stable identifier saved references use.
pub uid: AssetUid,
/// The asset's typed category.
pub kind: AssetKind,
/// Path relative to `assets/`, e.g. `"fonts/Inter-Regular.ttf"`.
pub path: String,
}
/// The on-disk manifest: the uid allocator plus every known entry.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Manifest {
/// The next uid to hand out; persisted so a deleted asset's uid is never
/// reused by a freshly imported one.
next_uid: u64,
/// Every recorded asset (sorted by uid when written, for stable diffs).
entries: Vec<AssetEntry>,
}
/// Maps stable asset ids to project-relative paths and back, and resolves them
/// to [`Handle`]s through an [`AssetServer`](super::AssetServer).
///
/// Construct it for a project root with [`new`](Self::new) (empty) or
/// [`open`](Self::open) (reading any existing manifest), then [`scan`](Self::scan)
/// the asset folders or [`register`](Self::register) individual imports. The
/// root may be changed with [`set_root`](Self::set_root) — e.g. after opening
/// the same project from a new location — without disturbing the uid mapping.
#[derive(Debug, Clone)]
pub struct AssetDatabase {
root: PathBuf,
by_uid: HashMap<AssetUid, AssetEntry>,
by_path: HashMap<String, AssetUid>,
next_uid: u64,
}
impl AssetDatabase {
/// An empty database for the project rooted at `root`.
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
by_uid: HashMap::new(),
by_path: HashMap::new(),
next_uid: 1,
}
}
/// Opens the database for the project at `root`, reading its manifest if
/// present. A missing or unreadable manifest yields an empty database (a
/// later [`scan`](Self::scan) repopulates it from disk).
pub fn open(root: impl AsRef<Path>) -> Self {
let root = root.as_ref().to_path_buf();
let mut db = Self::new(&root);
let manifest_path = root.join(ASSET_MANIFEST_FILE);
if let Ok(text) = std::fs::read_to_string(&manifest_path) {
if let Ok(manifest) = ron::from_str::<Manifest>(&text) {
for entry in manifest.entries {
db.by_path.insert(entry.path.clone(), entry.uid);
db.by_uid.insert(entry.uid, entry);
}
db.next_uid = manifest.next_uid.max(db.highest_uid() + 1);
}
}
db
}
/// Writes the manifest to `<root>/assets.manifest`.
pub fn save(&self) -> std::io::Result<()> {
let mut entries: Vec<AssetEntry> = self.by_uid.values().cloned().collect();
entries.sort_by_key(|e| e.uid);
let manifest = Manifest {
next_uid: self.next_uid,
entries,
};
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
.map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(self.manifest_path(), text)
}
/// The project root the database resolves paths against.
pub fn root(&self) -> &Path {
&self.root
}
/// Points the database at a new project root (e.g. after the project
/// directory moved). The uid ↔ relative-path mapping is unaffected, so all
/// existing references keep resolving — now against the new location.
pub fn set_root(&mut self, root: impl AsRef<Path>) {
self.root = root.as_ref().to_path_buf();
}
/// The `assets/` directory under the project root.
pub fn assets_dir(&self) -> PathBuf {
self.root.join(ASSETS_DIR)
}
/// The manifest file path.
pub fn manifest_path(&self) -> PathBuf {
self.root.join(ASSET_MANIFEST_FILE)
}
/// Records the asset at `relative_path` (relative to `assets/`), returning
/// its uid — the existing one if already known, else a freshly allocated
/// one. The kind is inferred from the path. Idempotent for a given path.
pub fn register(&mut self, relative_path: impl AsRef<str>) -> AssetUid {
let path = normalize_relative(relative_path.as_ref());
if let Some(&uid) = self.by_path.get(&path) {
return uid;
}
let uid = AssetUid(self.next_uid);
self.next_uid += 1;
let entry = AssetEntry {
uid,
kind: AssetKind::classify(&path),
path: path.clone(),
};
self.by_path.insert(path, uid);
self.by_uid.insert(uid, entry);
uid
}
/// Scans the typed asset folders under `assets/` and reconciles the
/// database with what is on disk: existing files keep their uid, new files
/// are [registered](Self::register), and entries whose files no longer exist
/// are dropped. Returns the number of newly registered assets.
///
/// Call [`save`](Self::save) afterwards to persist any new uids.
pub fn scan(&mut self) -> usize {
let assets_dir = self.assets_dir();
let mut found: Vec<String> = Vec::new();
for kind in AssetKind::TYPED {
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
}
// Drop entries whose backing file disappeared.
let present: std::collections::HashSet<&String> = found.iter().collect();
let removed: Vec<(AssetUid, String)> = self
.by_uid
.values()
.filter(|e| !present.contains(&e.path))
.map(|e| (e.uid, e.path.clone()))
.collect();
for (uid, path) in removed {
self.by_uid.remove(&uid);
self.by_path.remove(&path);
}
// Register anything new.
let before = self.by_uid.len();
for path in found {
self.register(path);
}
self.by_uid.len().saturating_sub(before)
}
/// The entry for `uid`, if known.
pub fn entry(&self, uid: AssetUid) -> Option<&AssetEntry> {
self.by_uid.get(&uid)
}
/// The uid recorded for an assets-relative path, if any.
pub fn uid_of(&self, relative_path: impl AsRef<str>) -> Option<AssetUid> {
self.by_path
.get(&normalize_relative(relative_path.as_ref()))
.copied()
}
/// The assets-relative path for `uid`, if known.
pub fn relative_path(&self, uid: AssetUid) -> Option<&str> {
self.by_uid.get(&uid).map(|e| e.path.as_str())
}
/// The absolute filesystem path for `uid` under the current root, if known.
pub fn absolute_path(&self, uid: AssetUid) -> Option<PathBuf> {
self.by_uid.get(&uid).map(|e| {
self.assets_dir()
.join(e.path.replace('/', std::path::MAIN_SEPARATOR_STR))
})
}
/// Every entry, in unspecified order.
pub fn entries(&self) -> impl Iterator<Item = &AssetEntry> {
self.by_uid.values()
}
/// Entries of a given kind, in unspecified order.
pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator<Item = &AssetEntry> {
self.by_uid.values().filter(move |e| e.kind == kind)
}
/// The number of recorded assets.
pub fn len(&self) -> usize {
self.by_uid.len()
}
/// Whether the database has no entries.
pub fn is_empty(&self) -> bool {
self.by_uid.is_empty()
}
/// Resolves `uid` to a loaded [`Handle<T>`] via `server`, or `None` if the
/// uid is unknown. The handle is deduplicated by the server, so resolving
/// the same uid (even after the project moved) yields the same asset.
pub fn load<T: Send + Sync + 'static>(
&self,
server: &AssetServer,
uid: AssetUid,
) -> Option<Handle<T>> {
let path = self.absolute_path(uid)?;
Some(server.load::<T>(path))
}
// --- internals ---------------------------------------------------------
fn highest_uid(&self) -> u64 {
self.by_uid.keys().map(|u| u.0).max().unwrap_or(0)
}
}
/// Normalizes a path to the database's canonical relative form: forward slashes,
/// no leading `./` or separator.
fn normalize_relative(path: &str) -> String {
let trimmed = path.replace('\\', "/");
let trimmed = trimmed.strip_prefix("./").unwrap_or(&trimmed);
trimmed.trim_start_matches('/').to_string()
}
/// Recursively collects files under `dir`, pushing each one's path relative to
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
for entry in read.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(&path, base, out);
} else if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_root(tag: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"oxide_assetdb_test_{}_{}_{tag}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst),
));
path
}
/// Creates `assets/<rel>` under `root` with placeholder contents.
fn touch_asset(root: &Path, rel: &str) {
let full = root.join(ASSETS_DIR).join(rel);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, b"x").unwrap();
}
#[test]
fn classify_by_folder_then_extension() {
assert_eq!(AssetKind::classify("fonts/Inter.ttf"), AssetKind::Font);
assert_eq!(AssetKind::classify("ui/menu.ron"), AssetKind::Ui);
assert_eq!(AssetKind::classify("textures/wall.png"), AssetKind::Texture);
// No typed folder → fall back to extension.
assert_eq!(AssetKind::classify("loose.glb"), AssetKind::Model);
assert_eq!(AssetKind::classify("notes.md"), AssetKind::Other);
}
#[test]
fn asset_ref_target_parses_and_maps_to_kind() {
// The derive's spelling (spaces around the generic args), for both the
// serializable `AssetRef<T>` and a bare `Handle<T>`.
assert_eq!(asset_ref_target("AssetRef < Font >"), Some("Font"));
assert_eq!(asset_ref_target("Handle < Font >"), Some("Font"));
// Compact and module-qualified spellings.
assert_eq!(asset_ref_target("AssetRef<GltfModel>"), Some("GltfModel"));
assert_eq!(asset_ref_target("Handle<crate::ui::Font>"), Some("Font"));
// Non-reference and unsupported (nested / multi-arg) fields.
assert_eq!(asset_ref_target("f32"), None);
assert_eq!(asset_ref_target("Vec<AssetRef<Font>>"), None);
assert_eq!(asset_ref_target("HashMap<String, u32>"), None);
// Target type -> picker filter kind.
assert_eq!(AssetKind::for_handle_target("Font"), Some(AssetKind::Font));
assert_eq!(
AssetKind::for_handle_target("GltfModel"),
Some(AssetKind::Model)
);
assert_eq!(AssetKind::for_handle_target("Whatever"), None);
}
#[test]
fn asset_ref_serializes_as_uid_and_resolves() {
// Empty and populated references round-trip through RON as just the uid.
let empty = AssetRef::<String>::none();
assert!(!empty.is_some());
let ron_empty = ron::to_string(&empty).unwrap();
assert_eq!(
ron::from_str::<AssetRef<String>>(&ron_empty).unwrap(),
empty
);
let r = AssetRef::<String>::new(AssetUid(7));
let round: AssetRef<String> = ron::from_str(&ron::to_string(&r).unwrap()).unwrap();
assert_eq!(round.uid(), Some(AssetUid(7)));
// resolve() goes ref -> db -> server -> handle.
let root = temp_root("assetref");
let full = root.join(ASSETS_DIR).join("textures");
std::fs::create_dir_all(&full).unwrap();
std::fs::write(full.join("a.txt"), "hi").unwrap();
let mut db = AssetDatabase::new(&root);
let uid = db.register("textures/a.txt");
let server = AssetServer::empty();
server.register_loader(TxtLoader);
let reference = AssetRef::<String>::new(uid);
let handle = reference.resolve(&db, &server).unwrap();
assert_eq!(handle.get().unwrap().as_str(), "hi");
// An empty ref resolves to nothing.
assert!(AssetRef::<String>::none().resolve(&db, &server).is_none());
std::fs::remove_dir_all(root).ok();
}
#[test]
fn register_is_idempotent_and_infers_kind() {
let mut db = AssetDatabase::new(temp_root("register"));
let a = db.register("fonts/Inter-Regular.ttf");
let b = db.register("fonts/Inter-Regular.ttf");
assert_eq!(a, b, "same path returns same uid");
assert_eq!(db.len(), 1);
assert_eq!(db.entry(a).unwrap().kind, AssetKind::Font);
// Path normalization: a `./`-prefixed, back-slashed spelling collapses.
assert_eq!(db.uid_of(".\\fonts\\Inter-Regular.ttf"), Some(a));
}
#[test]
fn scan_picks_up_typed_folders_and_prunes_missing() {
let root = temp_root("scan");
touch_asset(&root, "fonts/Inter.ttf");
touch_asset(&root, "textures/wall.png");
touch_asset(&root, "models/cube.glb");
let mut db = AssetDatabase::new(&root);
assert_eq!(db.scan(), 3);
assert_eq!(db.len(), 3);
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
// Remove one file and rescan: it is pruned, the rest keep their uids.
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
let wall_uid = db.uid_of("textures/wall.png").unwrap();
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
assert_eq!(db.scan(), 0);
assert_eq!(db.len(), 2);
assert!(db.entry(font_uid).is_none());
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
std::fs::remove_dir_all(root).ok();
}
#[test]
fn manifest_round_trips_uids() {
let root = temp_root("manifest");
std::fs::create_dir_all(&root).unwrap();
touch_asset(&root, "fonts/Inter.ttf");
touch_asset(&root, "audio/click.wav");
let mut db = AssetDatabase::new(&root);
db.scan();
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
let click_uid = db.uid_of("audio/click.wav").unwrap();
let next = db.next_uid;
db.save().unwrap();
// Reload from the manifest: every uid is preserved, and the allocator
// does not reuse a freed id.
let reloaded = AssetDatabase::open(&root);
assert_eq!(reloaded.uid_of("fonts/Inter.ttf"), Some(font_uid));
assert_eq!(reloaded.uid_of("audio/click.wav"), Some(click_uid));
assert_eq!(reloaded.next_uid, next);
std::fs::remove_dir_all(root).ok();
}
#[test]
fn reference_survives_save_load_and_moved_project() {
// A reference (uid) saved with the project must resolve to the same
// handle after reload AND after the whole project directory moves.
let root = temp_root("move_src");
touch_asset(&root, "fonts/Inter.ttf");
let mut db = AssetDatabase::open(&root);
db.scan();
db.save().unwrap();
let uid = db.uid_of("fonts/Inter.ttf").unwrap();
// Simulate moving the project to a new directory on disk.
let moved = temp_root("move_dst");
std::fs::create_dir_all(&moved).unwrap();
copy_dir(&root, &moved);
// Open the database from the new location: same uid, new absolute path.
let moved_db = AssetDatabase::open(&moved);
assert_eq!(moved_db.uid_of("fonts/Inter.ttf"), Some(uid));
let abs = moved_db.absolute_path(uid).unwrap();
assert!(abs.starts_with(&moved));
assert!(abs.exists());
std::fs::remove_dir_all(root).ok();
std::fs::remove_dir_all(moved).ok();
}
#[test]
fn load_dedups_through_the_server() {
// Use a tiny custom loader so we don't need a real asset format.
let root = temp_root("load");
let full = root.join(ASSETS_DIR).join("textures");
std::fs::create_dir_all(&full).unwrap();
std::fs::write(full.join("a.txt"), "hi").unwrap();
let mut db = AssetDatabase::new(&root);
let uid = db.register("textures/a.txt");
let server = AssetServer::empty();
server.register_loader(TxtLoader);
let h1 = db.load::<String>(&server, uid).unwrap();
let h2 = db.load::<String>(&server, uid).unwrap();
assert_eq!(h1.id(), h2.id(), "same uid resolves to one shared asset");
assert_eq!(h1.get().unwrap().as_str(), "hi");
assert!(db.load::<String>(&server, AssetUid(999)).is_none());
std::fs::remove_dir_all(root).ok();
}
struct TxtLoader;
impl crate::asset::AssetLoader for TxtLoader {
type Asset = String;
fn extensions(&self) -> &'static [&'static str] {
&["txt"]
}
fn load(&self, path: &Path) -> Result<String, crate::asset::AssetError> {
std::fs::read_to_string(path).map_err(|e| crate::asset::AssetError::Load {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
fn copy_dir(from: &Path, to: &Path) {
for entry in std::fs::read_dir(from).unwrap().flatten() {
let dst = to.join(entry.file_name());
if entry.path().is_dir() {
std::fs::create_dir_all(&dst).unwrap();
copy_dir(&entry.path(), &dst);
} else {
std::fs::copy(entry.path(), dst).unwrap();
}
}
}
}
+215
View File
@@ -0,0 +1,215 @@
//! glTF 2.0 static-mesh importer.
//!
//! Loads the mesh primitives of a glTF document into engine [`Mesh`]es, reading
//! their PBR-lite [`Material`] factors and the world [`Transform`] of each
//! placement (the node hierarchy is flattened into world space). Missing
//! normals are generated; missing UVs default to zero. Animation, skinning, and
//! textures are out of scope for Stage 4.
use std::path::Path;
use crate::math::{Color, Transform, Vec2, Vec3};
use crate::render::{Material, Mesh, Vertex};
/// Errors produced while importing a glTF document.
#[derive(Debug, thiserror::Error)]
pub enum GltfError {
/// The file could not be read or parsed as glTF.
#[error("failed to load glTF: {0}")]
Load(#[from] gltf::Error),
/// A mesh primitive was missing the required `POSITION` attribute.
#[error("glTF primitive has no POSITION attribute")]
MissingPositions,
}
/// One imported mesh placement: geometry, material, and world transform.
pub struct GltfMesh {
/// Optional node/mesh name from the document.
pub name: Option<String>,
/// The primitive's geometry.
pub mesh: Mesh,
/// The primitive's PBR-lite material.
pub material: Material,
/// World-space placement (node hierarchy flattened).
pub transform: Transform,
}
/// An imported glTF model: a flat list of mesh placements in world space.
pub struct GltfModel {
/// Every mesh primitive in the default scene, already placed in world space.
pub meshes: Vec<GltfMesh>,
}
impl GltfModel {
/// Total triangle count across all imported primitives.
pub fn triangle_count(&self) -> usize {
self.meshes.iter().map(|m| m.mesh.triangle_count()).sum()
}
}
/// Imports a glTF/GLB file from `path` (external buffers are resolved relative
/// to the file).
pub fn load_gltf(path: impl AsRef<Path>) -> Result<GltfModel, GltfError> {
let (document, buffers, _images) = gltf::import(path)?;
build_model(&document, &buffers)
}
/// The [`AssetServer`](super::AssetServer) loader for glTF/GLB files.
///
/// Registered by default (handles `.gltf` and `.glb`), so
/// `assets.load::<GltfModel>("model.gltf")` works out of the box; it simply
/// wraps [`load_gltf`] and adapts its error into [`AssetError`].
pub struct GltfLoader;
impl super::AssetLoader for GltfLoader {
type Asset = GltfModel;
fn extensions(&self) -> &'static [&'static str] {
&["gltf", "glb"]
}
fn load(&self, path: &Path) -> Result<GltfModel, super::AssetError> {
load_gltf(path).map_err(|err| super::AssetError::Load {
path: path.to_path_buf(),
message: err.to_string(),
})
}
}
/// Imports a glTF/GLB document from an in-memory byte slice (buffers must be
/// embedded; used for tests and bundled assets).
pub fn load_gltf_slice(bytes: &[u8]) -> Result<GltfModel, GltfError> {
let (document, buffers, _images) = gltf::import_slice(bytes)?;
build_model(&document, &buffers)
}
/// Walks the default scene's node hierarchy, accumulating world transforms and
/// emitting one [`GltfMesh`] per primitive.
fn build_model(
document: &gltf::Document,
buffers: &[gltf::buffer::Data],
) -> Result<GltfModel, GltfError> {
let mut meshes = Vec::new();
let scene = document
.default_scene()
.or_else(|| document.scenes().next());
if let Some(scene) = scene {
for node in scene.nodes() {
visit_node(&node, Transform::IDENTITY, buffers, &mut meshes)?;
}
}
Ok(GltfModel { meshes })
}
fn visit_node(
node: &gltf::Node,
parent: Transform,
buffers: &[gltf::buffer::Data],
out: &mut Vec<GltfMesh>,
) -> Result<(), GltfError> {
let world = parent.mul_transform(&node_transform(node));
if let Some(mesh) = node.mesh() {
for primitive in mesh.primitives() {
let geometry = read_primitive(&primitive, buffers)?;
out.push(GltfMesh {
name: node.name().or_else(|| mesh.name()).map(str::to_owned),
mesh: geometry,
material: read_material(&primitive),
transform: world,
});
}
}
for child in node.children() {
visit_node(&child, world, buffers, out)?;
}
Ok(())
}
/// Converts a node's local TRS into an engine [`Transform`].
fn node_transform(node: &gltf::Node) -> Transform {
let (t, r, s) = node.transform().decomposed();
Transform::from_trs(
Vec3::from_array(t),
glam::Quat::from_array(r),
Vec3::from_array(s),
)
}
/// Reads one primitive's vertices and indices into a [`Mesh`].
fn read_primitive(
primitive: &gltf::Primitive,
buffers: &[gltf::buffer::Data],
) -> Result<Mesh, GltfError> {
let reader = primitive.reader(|buffer| Some(&buffers[buffer.index()]));
let positions: Vec<[f32; 3]> = reader
.read_positions()
.ok_or(GltfError::MissingPositions)?
.collect();
let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|n| n.collect());
let uvs: Option<Vec<[f32; 2]>> = reader.read_tex_coords(0).map(|tc| tc.into_f32().collect());
let indices: Vec<u32> = match reader.read_indices() {
Some(idx) => idx.into_u32().collect(),
// Non-indexed primitive: every three positions form a triangle.
None => (0..positions.len() as u32).collect(),
};
// Generate flat normals when the document omits them, so lighting still works.
let normals = normals.unwrap_or_else(|| compute_normals(&positions, &indices));
let vertices = positions
.iter()
.enumerate()
.map(|(i, &p)| {
let n = normals.get(i).copied().unwrap_or([0.0, 1.0, 0.0]);
let uv = uvs
.as_ref()
.and_then(|u| u.get(i))
.copied()
.unwrap_or([0.0, 0.0]);
Vertex::new(
Vec3::from_array(p),
Vec3::from_array(n),
Vec2::from_array(uv),
)
})
.collect();
Ok(Mesh::new(vertices, indices))
}
/// Smooth per-vertex normals: accumulate each triangle's face normal at its
/// vertices, then normalize.
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
let mut normals = vec![Vec3::ZERO; positions.len()];
for tri in indices.chunks_exact(3) {
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
let pa = Vec3::from_array(positions[a]);
let pb = Vec3::from_array(positions[b]);
let pc = Vec3::from_array(positions[c]);
let face = (pb - pa).cross(pc - pa);
normals[a] += face;
normals[b] += face;
normals[c] += face;
}
normals
.into_iter()
.map(|n| n.normalize_or_zero().to_array())
.collect()
}
/// Maps a primitive's PBR metallic-roughness factors onto a [`Material`].
fn read_material(primitive: &gltf::Primitive) -> Material {
let pbr = primitive.material().pbr_metallic_roughness();
let [r, g, b, a] = pbr.base_color_factor();
Material {
albedo: Color::rgba(r, g, b, a),
metallic: pbr.metallic_factor(),
roughness: pbr.roughness_factor(),
}
}
+199
View File
@@ -0,0 +1,199 @@
//! [`Handle`]: a typed, ref-counted reference to a loaded asset.
//!
//! A handle is the unit of *ownership* in the asset system. It is cheap to clone
//! (an `Arc` bump), and the asset behind it lives exactly as long as at least
//! one handle does — drop the last handle and the asset is freed. The
//! [`AssetServer`](super::AssetServer) keeps only a [`Weak`] reference in its
//! dedup cache, so it never keeps an otherwise-unused asset alive.
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex};
/// A process-unique identifier assigned to every asset slot.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AssetId(pub(crate) u64);
impl AssetId {
/// The raw numeric id.
pub fn value(self) -> u64 {
self.0
}
}
/// The lifecycle state of an asset behind a [`Handle`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadState {
/// A background load is in progress; the value is not ready yet.
Loading,
/// The asset loaded successfully and can be read with [`Handle::get`].
Loaded,
/// Loading failed; see [`Handle::error`] for why.
Failed,
}
/// The interior of an asset slot: its current state and (once ready) the value.
///
/// The value is stored as an `Arc<T>` so it can be cloned out cheaply and so a
/// live reload can swap in fresh contents without disturbing readers that
/// already hold the previous `Arc`.
pub(crate) enum CellState<T> {
Loading,
Loaded(Arc<T>),
Failed(Arc<str>),
}
/// The shared, reference-counted storage for one asset.
///
/// Handles hold an `Arc<AssetCell<T>>`; the server's cache holds a
/// `Weak<dyn Any>` to the same allocation for deduplication only.
pub(crate) struct AssetCell<T> {
id: AssetId,
source: Option<PathBuf>,
state: Mutex<CellState<T>>,
ready: Condvar,
}
impl<T> AssetCell<T> {
pub(crate) fn new_loading(id: AssetId, source: Option<PathBuf>) -> Arc<Self> {
Arc::new(Self {
id,
source,
state: Mutex::new(CellState::Loading),
ready: Condvar::new(),
})
}
pub(crate) fn new_loaded(id: AssetId, source: Option<PathBuf>, value: T) -> Arc<Self> {
Arc::new(Self {
id,
source,
state: Mutex::new(CellState::Loaded(Arc::new(value))),
ready: Condvar::new(),
})
}
pub(crate) fn new_failed(id: AssetId, source: Option<PathBuf>, message: String) -> Arc<Self> {
Arc::new(Self {
id,
source,
state: Mutex::new(CellState::Failed(Arc::from(message))),
ready: Condvar::new(),
})
}
pub(crate) fn set_loaded(&self, value: T) {
*self.state.lock().unwrap() = CellState::Loaded(Arc::new(value));
self.ready.notify_all();
}
pub(crate) fn set_failed(&self, message: String) {
*self.state.lock().unwrap() = CellState::Failed(Arc::from(message));
self.ready.notify_all();
}
}
/// A typed, reference-counted handle to an asset of type `T`.
///
/// Clone it freely to share ownership; the asset is freed when the last handle
/// is dropped. Read the value with [`get`](Self::get) (returns `None` until the
/// asset is loaded) or block for it with [`wait`](Self::wait).
pub struct Handle<T> {
cell: Arc<AssetCell<T>>,
}
impl<T> Handle<T> {
pub(crate) fn from_cell(cell: Arc<AssetCell<T>>) -> Self {
Self { cell }
}
/// This asset's process-unique id.
pub fn id(&self) -> AssetId {
self.cell.id
}
/// The source path the asset was loaded from, if any (in-memory assets added
/// with [`AssetServer::add`](super::AssetServer::add) have none).
pub fn source(&self) -> Option<&Path> {
self.cell.source.as_deref()
}
/// The current lifecycle state.
pub fn state(&self) -> LoadState {
match &*self.cell.state.lock().unwrap() {
CellState::Loading => LoadState::Loading,
CellState::Loaded(_) => LoadState::Loaded,
CellState::Failed(_) => LoadState::Failed,
}
}
/// Whether the asset has finished loading successfully.
pub fn is_loaded(&self) -> bool {
matches!(&*self.cell.state.lock().unwrap(), CellState::Loaded(_))
}
/// The loaded value as a cheap `Arc<T>` clone, or `None` if it is still
/// loading or failed.
pub fn get(&self) -> Option<Arc<T>> {
match &*self.cell.state.lock().unwrap() {
CellState::Loaded(value) => Some(value.clone()),
_ => None,
}
}
/// The error message if loading failed, else `None`.
pub fn error(&self) -> Option<String> {
match &*self.cell.state.lock().unwrap() {
CellState::Failed(message) => Some(message.to_string()),
_ => None,
}
}
/// Blocks until the asset is no longer [`Loading`](LoadState::Loading),
/// returning the value on success or `None` if it failed.
pub fn wait(&self) -> Option<Arc<T>> {
let mut guard = self.cell.state.lock().unwrap();
loop {
match &*guard {
CellState::Loading => guard = self.cell.ready.wait(guard).unwrap(),
CellState::Loaded(value) => return Some(value.clone()),
CellState::Failed(_) => return None,
}
}
}
/// The number of live handles to this asset (including this one). The
/// server holds only a weak reference, so this counts handles alone.
pub fn ref_count(&self) -> usize {
Arc::strong_count(&self.cell)
}
/// Replaces the asset's contents in place; every existing handle observes
/// the new value on its next [`get`](Self::get). Used by live reload.
pub(crate) fn set_loaded(&self, value: T) {
self.cell.set_loaded(value);
}
/// Marks the asset as failed in place.
pub(crate) fn set_failed(&self, message: String) {
self.cell.set_failed(message);
}
}
impl<T> Clone for Handle<T> {
fn clone(&self) -> Self {
Self {
cell: self.cell.clone(),
}
}
}
impl<T> fmt::Debug for Handle<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Handle")
.field("id", &self.cell.id.0)
.field("state", &self.state())
.field("source", &self.cell.source)
.finish()
}
}
+32
View File
@@ -0,0 +1,32 @@
//! Asset loading and management.
//!
//! Stage 4 introduced the first importer: a static-mesh [`glTF`](gltf) loader
//! that turns a `.gltf`/`.glb` file into engine [`Mesh`](crate::render::Mesh)es,
//! [`Material`](crate::render::Material)s, and placement [`Transform`](crate::math::Transform)s.
//!
//! Stage 5 adds the [`AssetServer`]: a central registry that loads assets through
//! pluggable [`AssetLoader`]s, deduplicates by path+type, and hands out
//! reference-counted [`Handle`]s (an asset lives as long as a handle to it does).
//! It supports synchronous and background loading and in-place [reload](AssetServer::reload),
//! the foundation later stages build live reload, streaming, and export packing
//! on. The standalone [`load_gltf`] importer stays available; the server reaches
//! it through the built-in [`GltfLoader`].
mod database;
mod gltf;
mod handle;
mod server;
pub use database::{
asset_ref_target, AssetDatabase, AssetEntry, AssetKind, AssetRef, AssetUid, ASSET_MANIFEST_FILE,
};
pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel};
pub use handle::{AssetId, Handle, LoadState};
pub use server::{AssetError, AssetLoader, AssetServer};
/// Registers the engine's built-in asset loaders on `server`. Called by
/// [`AssetServer::new`].
pub(crate) fn register_default_loaders(server: &AssetServer) {
server.register_loader(GltfLoader);
server.register_loader(crate::ui::FontLoader);
}
+508
View File
@@ -0,0 +1,508 @@
//! [`AssetServer`]: the central registry that loads, deduplicates, and hands out
//! [`Handle`]s, plus the [`AssetLoader`] trait that makes it extensible.
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock, Weak};
use super::handle::{AssetCell, AssetId, Handle};
/// Errors produced while loading assets.
#[derive(Debug, thiserror::Error)]
pub enum AssetError {
/// The path had no file extension to pick a loader by.
#[error("path has no file extension: {0}")]
NoExtension(PathBuf),
/// No loader was registered for the file's extension.
#[error("no loader registered for extension '.{0}'")]
NoLoader(String),
/// A loader exists for the extension, but it produces a different asset
/// type than the one requested at the call site.
#[error("loader for '.{ext}' produces a different asset type than requested")]
TypeMismatch {
/// The extension whose loader was selected.
ext: String,
},
/// The loader itself failed (I/O, parse, etc.).
#[error("failed to load {path}: {message}")]
Load {
/// The asset path.
path: PathBuf,
/// The loader's error message.
message: String,
},
}
/// A pluggable importer that turns a file into an asset of one concrete type.
///
/// Implement this for each asset format and register it with
/// [`AssetServer::register_loader`]. The server dispatches by file extension and
/// checks that the loader's [`Asset`](Self::Asset) type matches what the caller
/// asked to load.
pub trait AssetLoader: Send + Sync + 'static {
/// The type this loader produces.
type Asset: Send + Sync + 'static;
/// The lower-or-mixed-case extensions (without the dot) this loader handles,
/// e.g. `&["gltf", "glb"]`.
fn extensions(&self) -> &'static [&'static str];
/// Loads and parses the asset at `path`.
fn load(&self, path: &Path) -> Result<Self::Asset, AssetError>;
}
/// Type-erased view of an [`AssetLoader`] so loaders of different output types
/// can share one registry.
trait ErasedLoader: Send + Sync {
fn output_type(&self) -> TypeId;
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError>;
}
impl<L: AssetLoader> ErasedLoader for L {
fn output_type(&self) -> TypeId {
TypeId::of::<L::Asset>()
}
fn load(&self, path: &Path) -> Result<Box<dyn Any + Send + Sync>, AssetError> {
Ok(Box::new(<L as AssetLoader>::load(self, path)?))
}
}
type CacheKey = (TypeId, PathBuf);
/// One entry in the dedup cache. Carries a weak reference to the asset cell so
/// dropped assets are pruned, plus a function pointer that knows how to rerun
/// the loader for the cell's concrete type. Storing the reload-by-type as a
/// per-entry `fn` is what lets [`AssetServer::reload_path`] reload an asset
/// without knowing its `T` at the call site — the original `insert_cache::<T>`
/// captures `T` into the function pointer.
#[derive(Clone)]
struct CacheEntry {
weak: Weak<dyn Any + Send + Sync>,
reload_in_place: fn(&AssetServer, &Path),
}
struct Inner {
loaders: RwLock<HashMap<String, Arc<dyn ErasedLoader>>>,
/// Dedup cache: weak references, so a cached asset with no live handles is
/// collected and reloaded fresh next time.
cache: Mutex<HashMap<CacheKey, CacheEntry>>,
next_id: AtomicU64,
}
/// The central asset registry.
///
/// Cloning an `AssetServer` is cheap (it shares one inner state via `Arc`) so it
/// can be handed to background load threads and stored across systems. Loading
/// the same path+type twice returns handles to **one** shared asset; when the
/// last handle is dropped the asset is freed.
///
/// ```no_run
/// use oxide_engine::asset::AssetServer;
/// use oxide_engine::asset::GltfModel;
///
/// let assets = AssetServer::new(); // glTF loader registered by default
/// let model = assets.load::<GltfModel>("assets/models/cube.gltf");
/// if let Some(model) = model.get() {
/// println!("{} meshes", model.meshes.len());
/// }
/// ```
#[derive(Clone)]
pub struct AssetServer {
inner: Arc<Inner>,
}
impl AssetServer {
/// A server with the engine's built-in loaders registered (currently glTF).
pub fn new() -> Self {
let server = Self::empty();
super::register_default_loaders(&server);
server
}
/// A server with **no** loaders registered. Use [`register_loader`] to add
/// them; handy for tests or fully custom asset pipelines.
///
/// [`register_loader`]: Self::register_loader
pub fn empty() -> Self {
Self {
inner: Arc::new(Inner {
loaders: RwLock::new(HashMap::new()),
cache: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
}),
}
}
/// Registers `loader`, mapping each of its extensions to it.
pub fn register_loader<L: AssetLoader>(&self, loader: L) {
let exts: Vec<String> = loader
.extensions()
.iter()
.map(|e| e.to_lowercase())
.collect();
let erased: Arc<dyn ErasedLoader> = Arc::new(loader);
let mut loaders = self.inner.loaders.write().unwrap();
for ext in exts {
loaders.insert(ext, erased.clone());
}
}
/// Removes the loader registered for `extension` (without the dot). Returns
/// whether one was present. Used when a module that added a loader is removed.
pub fn unregister_loader(&self, extension: &str) -> bool {
self.inner
.loaders
.write()
.unwrap()
.remove(&extension.to_lowercase())
.is_some()
}
/// Loads the asset at `path` as type `T`, blocking until it is ready.
///
/// Returns a handle to a cached asset if one of the same path+type is
/// already live. On failure the returned handle is in the
/// [`Failed`](super::LoadState::Failed) state (inspect [`Handle::error`]).
pub fn load<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
let path = path.as_ref().to_path_buf();
let key = (TypeId::of::<T>(), path.clone());
if let Some(handle) = self.cached::<T>(&key) {
return handle;
}
match self.run_loader::<T>(&path) {
Ok(value) => {
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
self.insert_cache(key, &cell);
Handle::from_cell(cell)
}
// Failures are not cached, so a later load retries from scratch.
Err(err) => Handle::from_cell(AssetCell::new_failed(
self.next_id(),
Some(path),
err.to_string(),
)),
}
}
/// Loads the asset at `path` as type `T` on a background thread, returning a
/// handle immediately in the [`Loading`](super::LoadState::Loading) state.
///
/// Poll [`Handle::state`]/[`Handle::get`], or block with [`Handle::wait`].
pub fn load_async<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
let path = path.as_ref().to_path_buf();
let key = (TypeId::of::<T>(), path.clone());
if let Some(handle) = self.cached::<T>(&key) {
return handle;
}
// Insert the loading cell up front so concurrent requests dedup onto it.
let cell = AssetCell::<T>::new_loading(self.next_id(), Some(path.clone()));
self.insert_cache(key.clone(), &cell);
let server = self.clone();
let worker_cell = cell.clone();
std::thread::spawn(move || match server.run_loader::<T>(&path) {
Ok(value) => worker_cell.set_loaded(value),
Err(err) => {
worker_cell.set_failed(err.to_string());
// Don't leave a failed slot cached.
server.inner.cache.lock().unwrap().remove(&key);
}
});
Handle::from_cell(cell)
}
/// Adds an already-constructed, in-memory asset and returns a handle to it.
/// In-memory assets have no source path and are not cached for dedup.
pub fn add<T: Send + Sync + 'static>(&self, value: T) -> Handle<T> {
Handle::from_cell(AssetCell::new_loaded(self.next_id(), None, value))
}
/// Returns a handle to an already-loaded asset of this path+type, if one is
/// still live, without triggering a load.
pub fn get<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Option<Handle<T>> {
let key = (TypeId::of::<T>(), path.as_ref().to_path_buf());
self.cached::<T>(&key)
}
/// Re-runs the loader for `path` and updates the existing asset in place, so
/// every live handle observes the new contents. If no handle is currently
/// live, behaves like [`load`](Self::load). This is the foundation the
/// live-reload stage builds on.
pub fn reload<T: Send + Sync + 'static>(&self, path: impl AsRef<Path>) -> Handle<T> {
let path = path.as_ref().to_path_buf();
let key = (TypeId::of::<T>(), path.clone());
let existing = self.cached::<T>(&key);
match self.run_loader::<T>(&path) {
Ok(value) => match existing {
Some(handle) => {
handle.set_loaded(value);
handle
}
None => {
let cell = AssetCell::new_loaded(self.next_id(), Some(path), value);
self.insert_cache(key, &cell);
Handle::from_cell(cell)
}
},
Err(err) => match existing {
Some(handle) => {
handle.set_failed(err.to_string());
handle
}
None => Handle::from_cell(AssetCell::new_failed(
self.next_id(),
Some(path),
err.to_string(),
)),
},
}
}
/// The number of distinct assets still alive (have at least one live
/// handle). Prunes collected entries as a side effect.
pub fn live_asset_count(&self) -> usize {
let mut cache = self.inner.cache.lock().unwrap();
cache.retain(|_, entry| entry.weak.strong_count() > 0);
cache.len()
}
/// Reruns the loader for every cached asset whose source path is `path`,
/// updating each existing handle in place. Returns the number of assets
/// reloaded.
///
/// Unlike [`reload`](Self::reload) this does **not** need `T` at the call
/// site — it dispatches on what types are actually cached for `path`. The
/// file-watcher uses this to react to disk changes without knowing every
/// asset type at compile time. Paths that are not currently cached return
/// `0`; they will be loaded fresh by the next [`load`](Self::load) call.
pub fn reload_path(&self, path: &Path) -> usize {
// Snapshot the set of typed reload fns to call so we don't hold the
// cache lock while re-running loaders (which would deadlock — `reload`
// takes the lock too).
let reloaders: Vec<fn(&AssetServer, &Path)> = {
let cache = self.inner.cache.lock().unwrap();
cache
.iter()
.filter_map(|(key, entry)| {
if key.1 == path && entry.weak.strong_count() > 0 {
Some(entry.reload_in_place)
} else {
None
}
})
.collect()
};
let n = reloaders.len();
for f in reloaders {
f(self, path);
}
n
}
// --- internals ---------------------------------------------------------
fn next_id(&self) -> AssetId {
AssetId(self.inner.next_id.fetch_add(1, Ordering::Relaxed))
}
fn cached<T: Send + Sync + 'static>(&self, key: &CacheKey) -> Option<Handle<T>> {
let cache = self.inner.cache.lock().unwrap();
let arc = cache.get(key)?.weak.upgrade()?;
let cell = arc.downcast::<AssetCell<T>>().ok()?;
Some(Handle::from_cell(cell))
}
fn insert_cache<T: Send + Sync + 'static>(&self, key: CacheKey, cell: &Arc<AssetCell<T>>) {
let erased: Arc<dyn Any + Send + Sync> = cell.clone();
// `reload_in_place` keeps the concrete `T` in its signature, so the
// path-keyed `reload_path` can rebuild the typed handle without
// knowing `T` at the call site.
let entry = CacheEntry {
weak: Arc::downgrade(&erased),
reload_in_place: |server, path| {
server.reload::<T>(path);
},
};
self.inner.cache.lock().unwrap().insert(key, entry);
}
fn run_loader<T: Send + Sync + 'static>(&self, path: &Path) -> Result<T, AssetError> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.ok_or_else(|| AssetError::NoExtension(path.to_path_buf()))?
.to_lowercase();
let loader = self
.inner
.loaders
.read()
.unwrap()
.get(&ext)
.cloned()
.ok_or_else(|| AssetError::NoLoader(ext.clone()))?;
if loader.output_type() != TypeId::of::<T>() {
return Err(AssetError::TypeMismatch { ext });
}
let boxed = loader.load(path)?;
Ok(*boxed
.downcast::<T>()
.expect("loader output_type matched the request but downcast failed"))
}
}
impl Default for AssetServer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::asset::LoadState;
use std::sync::atomic::{AtomicU32, Ordering};
// A trivial asset + loader: each "load" reads a file's text and counts how
// many times the loader actually ran, so dedup can be observed.
struct Counter(Arc<AtomicU32>);
#[derive(Debug, PartialEq, Eq)]
struct TextAsset(String);
struct TextLoader(Arc<AtomicU32>);
impl AssetLoader for TextLoader {
type Asset = TextAsset;
fn extensions(&self) -> &'static [&'static str] {
&["txt"]
}
fn load(&self, path: &Path) -> Result<TextAsset, AssetError> {
self.0.fetch_add(1, Ordering::SeqCst);
let text = std::fs::read_to_string(path).map_err(|e| AssetError::Load {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok(TextAsset(text.trim().to_string()))
}
}
fn temp_file(name: &str, contents: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"oxide_asset_test_{}_{name}.txt",
std::process::id()
));
std::fs::write(&path, contents).unwrap();
path
}
fn server() -> (AssetServer, Counter) {
let counter = Arc::new(AtomicU32::new(0));
let server = AssetServer::empty();
server.register_loader(TextLoader(counter.clone()));
(server, Counter(counter))
}
#[test]
fn loads_and_reads_an_asset() {
let (server, _c) = server();
let path = temp_file("hello", " hello world ");
let handle = server.load::<TextAsset>(&path);
assert_eq!(handle.state(), LoadState::Loaded);
assert_eq!(handle.get().unwrap().0, "hello world");
assert_eq!(handle.source(), Some(path.as_path()));
std::fs::remove_file(path).ok();
}
#[test]
fn loading_twice_yields_one_resource() {
let (server, c) = server();
let path = temp_file("dedup", "data");
let a = server.load::<TextAsset>(&path);
let b = server.load::<TextAsset>(&path);
// Same allocation: loader ran once, ids match, two handles share it.
assert_eq!(c.0.load(Ordering::SeqCst), 1);
assert_eq!(a.id(), b.id());
assert_eq!(a.ref_count(), 2);
assert_eq!(server.live_asset_count(), 1);
std::fs::remove_file(path).ok();
}
#[test]
fn dropping_all_handles_frees_the_asset() {
let (server, _c) = server();
let path = temp_file("free", "data");
let handle = server.load::<TextAsset>(&path);
assert_eq!(server.live_asset_count(), 1);
drop(handle);
// With no live handles, the weak cache entry is dead and pruned.
assert_eq!(server.live_asset_count(), 0);
assert!(server.get::<TextAsset>(&path).is_none());
std::fs::remove_file(path).ok();
}
#[test]
fn missing_loader_and_type_mismatch_are_distinct_errors() {
let (server, _c) = server();
let path = temp_file("x", "data");
// No loader for ".dat".
let bad_ext = path.with_extension("dat");
std::fs::write(&bad_ext, "data").unwrap();
let h = server.load::<TextAsset>(&bad_ext);
assert_eq!(h.state(), LoadState::Failed);
assert!(h.error().unwrap().contains("no loader"));
// A ".txt" loader exists but produces TextAsset, not String.
let renamed = path.with_extension("txt");
std::fs::write(&renamed, "data").unwrap();
let h2 = server.load::<String>(&renamed);
assert!(h2.error().unwrap().contains("different asset type"));
std::fs::remove_file(path).ok();
std::fs::remove_file(bad_ext).ok();
std::fs::remove_file(renamed).ok();
}
#[test]
fn async_load_completes_and_dedups() {
let (server, c) = server();
let path = temp_file("async", "background");
let handle = server.load_async::<TextAsset>(&path);
let value = handle.wait().expect("async load should succeed");
assert_eq!(value.0, "background");
// A second request dedups onto the same now-loaded asset.
let again = server.load::<TextAsset>(&path);
assert_eq!(again.id(), handle.id());
assert_eq!(c.0.load(Ordering::SeqCst), 1);
std::fs::remove_file(path).ok();
}
#[test]
fn reload_updates_in_place_for_existing_handles() {
let (server, _c) = server();
let path = temp_file("reload", "before");
let handle = server.load::<TextAsset>(&path);
assert_eq!(handle.get().unwrap().0, "before");
// Change the file on disk and reload: the SAME handle sees new contents.
std::fs::write(&path, "after").unwrap();
let reloaded = server.reload::<TextAsset>(&path);
assert_eq!(reloaded.id(), handle.id());
assert_eq!(handle.get().unwrap().0, "after");
std::fs::remove_file(path).ok();
}
#[test]
fn add_stores_in_memory_assets() {
let (server, _c) = server();
let handle = server.add(TextAsset("in-memory".to_string()));
assert_eq!(handle.get().unwrap().0, "in-memory");
assert!(handle.source().is_none());
}
}
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
//! [`AxisBinding`] and [`Axis2DBinding`] — directional inputs composed from
//! [`Binding`]s into floats and [`Vec2`]s.
//!
//! A 1D axis pairs a "positive" binding set with a "negative" binding set;
//! each direction held contributes ±1. If both directions are held the
//! contributions cancel and the axis reads 0 — a "soft brake" any third-
//! person camera or twin-stick character controller needs out of the box.
//! Each direction supports several bindings (a WASD axis can also accept
//! arrow keys), and the same physical key can appear in many axes' direction
//! sets.
//!
//! A 2D axis is just a pair of 1D axes (X then Y). Diagonals are
//! intentionally **not** normalized at this layer — some games want
//! Quake-style diagonal speedup, others want unit-length input. Whichever
//! convention a game wants, applying it once at the call site is clearer
//! than having to undo a default at every site that disagrees.
use serde::{Deserialize, Serialize};
use crate::math::Vec2;
use super::{Binding, InputState};
/// One direction of an axis — typically positive (right / forward / up) or
/// negative (left / back / down) — bound to one or more physical inputs.
/// Any binding held contributes a full unit; multiple held bindings on the
/// same direction do not stack.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AxisBinding {
/// Bindings that pull the axis toward +1.
pub positive: Vec<Binding>,
/// Bindings that pull the axis toward -1.
pub negative: Vec<Binding>,
}
impl AxisBinding {
/// A new axis with the given direction binding lists.
pub fn new(
positive: impl IntoIterator<Item = Binding>,
negative: impl IntoIterator<Item = Binding>,
) -> Self {
Self {
positive: positive.into_iter().collect(),
negative: negative.into_iter().collect(),
}
}
/// Evaluates the axis against `input`. Returns -1, 0, or +1 (the
/// directions OR'd together — multiple held bindings on the same side
/// don't stack).
pub fn value(&self, input: &InputState) -> f32 {
let pos = self.positive.iter().any(|b| b.held(input));
let neg = self.negative.iter().any(|b| b.held(input));
match (pos, neg) {
(true, false) => 1.0,
(false, true) => -1.0,
// Both held → mutual cancel; neither → idle. Same result.
_ => 0.0,
}
}
}
/// A 2D axis composed of two [`AxisBinding`]s (X and Y).
///
/// Output is the unmodified vector `(x.value, y.value)` — diagonals are
/// `(±1, ±1)`, magnitude √2. Normalize at the call site if your game wants
/// unit-length movement.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Axis2DBinding {
/// The X (right left) axis.
pub x: AxisBinding,
/// The Y (up down) axis.
pub y: AxisBinding,
}
impl Axis2DBinding {
/// A 2D axis from four direction binding lists in the usual order
/// (`right`, `left`, `up`, `down`).
pub fn new(
right: impl IntoIterator<Item = Binding>,
left: impl IntoIterator<Item = Binding>,
up: impl IntoIterator<Item = Binding>,
down: impl IntoIterator<Item = Binding>,
) -> Self {
Self {
x: AxisBinding::new(right, left),
y: AxisBinding::new(up, down),
}
}
/// Evaluates the axis against `input`, returning the raw `(x, y)` value
/// without normalization.
pub fn value(&self, input: &InputState) -> Vec2 {
Vec2::new(self.x.value(input), self.y.value(input))
}
}
#[cfg(test)]
mod tests {
use super::*;
use winit::keyboard::KeyCode;
fn ad_axis() -> AxisBinding {
AxisBinding::new([Binding::Key(KeyCode::KeyD)], [Binding::Key(KeyCode::KeyA)])
}
#[test]
fn idle_axis_is_zero() {
let input = InputState::new();
assert_eq!(ad_axis().value(&input), 0.0);
}
#[test]
fn positive_direction_returns_plus_one() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyD);
assert_eq!(ad_axis().value(&input), 1.0);
}
#[test]
fn negative_direction_returns_minus_one() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyA);
assert_eq!(ad_axis().value(&input), -1.0);
}
#[test]
fn both_directions_held_cancel_to_zero() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyA);
input.press_key(KeyCode::KeyD);
assert_eq!(
ad_axis().value(&input),
0.0,
"left+right held simultaneously must read as idle"
);
}
#[test]
fn multi_bindings_on_same_direction_do_not_stack() {
// WASD + arrow keys both contribute, but holding two positives is
// still +1 (not +2). The axis is a directional indicator, not an
// accumulator.
let axis = AxisBinding::new(
[
Binding::Key(KeyCode::KeyD),
Binding::Key(KeyCode::ArrowRight),
],
[
Binding::Key(KeyCode::KeyA),
Binding::Key(KeyCode::ArrowLeft),
],
);
let mut input = InputState::new();
input.press_key(KeyCode::KeyD);
input.press_key(KeyCode::ArrowRight);
assert_eq!(axis.value(&input), 1.0);
}
#[test]
fn axis_2d_returns_vector_components_independently() {
let axis = 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::KeyD);
input.press_key(KeyCode::KeyW);
assert_eq!(axis.value(&input), Vec2::new(1.0, 1.0));
input.release_key(KeyCode::KeyD);
input.press_key(KeyCode::KeyA);
// Now A + W held.
assert_eq!(axis.value(&input), Vec2::new(-1.0, 1.0));
}
#[test]
fn axis_2d_diagonal_is_unnormalized() {
// Diagonals are (±1, ±1) — caller normalizes if it cares.
let axis = 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::KeyD);
input.press_key(KeyCode::KeyW);
let v = axis.value(&input);
assert!(
(v.length() - 2_f32.sqrt()).abs() < 1e-6,
"diagonal must be sqrt(2), got {}",
v.length()
);
}
#[test]
fn axis_ron_round_trip() {
let axis = Axis2DBinding::new(
[Binding::Key(KeyCode::KeyD)],
[Binding::Key(KeyCode::KeyA)],
[Binding::Key(KeyCode::KeyW)],
[Binding::Key(KeyCode::KeyS)],
);
let s = ron::to_string(&axis).unwrap();
let parsed: Axis2DBinding = ron::from_str(&s).unwrap();
assert_eq!(parsed, axis);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! [`Binding`] — one physical input that can drive a named action.
//!
//! A binding is the smallest unit an [`ActionMap`](super::ActionMap) maps
//! action names to. The enum is intentionally small (keys and mouse buttons
//! today; gamepad / pointer-axis variants will be added without breaking
//! existing serialized maps as long as new variants are appended).
use serde::{Deserialize, Serialize};
use winit::event::MouseButton;
use winit::keyboard::KeyCode;
use super::InputState;
/// One physical input that can be bound to a named action.
///
/// Two bindings compare equal only if they refer to the exact same physical
/// input — the enum derives `Hash`/`Eq` so a `HashSet<Binding>` can be used
/// to deduplicate a key's contribution to multiple actions without
/// allocating per-action sets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Binding {
/// A keyboard key, identified by layout-independent physical position
/// (the same `KeyCode` an [`InputState`] query takes).
Key(KeyCode),
/// A mouse button.
Mouse(MouseButton),
}
impl Binding {
/// `true` if this binding's `pressed` edge fired in `input` this frame.
pub fn pressed(&self, input: &InputState) -> bool {
match *self {
Binding::Key(k) => input.pressed(k),
Binding::Mouse(b) => input.mouse_pressed(b),
}
}
/// `true` if this binding's `released` edge fired in `input` this frame.
pub fn released(&self, input: &InputState) -> bool {
match *self {
Binding::Key(k) => input.released(k),
Binding::Mouse(b) => input.mouse_released(b),
}
}
/// `true` if this binding is currently held down in `input`.
pub fn held(&self, input: &InputState) -> bool {
match *self {
Binding::Key(k) => input.held(k),
Binding::Mouse(b) => input.mouse_held(b),
}
}
/// `true` if this binding was held *going into* this frame — i.e. it was
/// held continuously from before the current frame's events arrived.
/// Used by [`ActionMap`](super::ActionMap) to recover prior-frame state
/// from the current frame's snapshot alone, without storing a previous
/// `InputState`.
///
/// Derivation: a binding was held before the frame iff it is currently
/// held or was released this frame (either way it was down going in),
/// **except** when it was also pressed this frame — a same-frame tap
/// goes idle → pressed → released, so it was not held going in.
pub(crate) fn held_before_frame(&self, input: &InputState) -> bool {
(self.held(input) || self.released(input)) && !self.pressed(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_binding_routes_to_keyboard_queries() {
let mut input = InputState::new();
let b = Binding::Key(KeyCode::Space);
input.press_key(KeyCode::Space);
assert!(b.pressed(&input));
assert!(b.held(&input));
assert!(!b.released(&input));
input.end_frame();
assert!(!b.pressed(&input));
assert!(b.held(&input));
input.release_key(KeyCode::Space);
assert!(b.released(&input));
assert!(!b.held(&input));
}
#[test]
fn mouse_binding_routes_to_mouse_queries() {
let mut input = InputState::new();
let b = Binding::Mouse(MouseButton::Right);
input.press_mouse(MouseButton::Right);
assert!(b.pressed(&input));
assert!(b.held(&input));
input.end_frame();
input.release_mouse(MouseButton::Right);
assert!(b.released(&input));
assert!(!b.held(&input));
}
#[test]
fn held_before_frame_distinguishes_press_release_tap() {
let b = Binding::Key(KeyCode::KeyJ);
// Idle → pressed this frame. Not held before.
let mut input = InputState::new();
input.press_key(KeyCode::KeyJ);
assert!(!b.held_before_frame(&input));
// Held continuously. Held before.
let mut input = InputState::new();
input.press_key(KeyCode::KeyJ);
input.end_frame();
assert!(b.held_before_frame(&input));
// Held → released this frame. Held before.
let mut input = InputState::new();
input.press_key(KeyCode::KeyJ);
input.end_frame();
input.release_key(KeyCode::KeyJ);
assert!(b.held_before_frame(&input));
// Same-frame tap (idle → pressed → released). Not held before.
let mut input = InputState::new();
input.press_key(KeyCode::KeyJ);
input.release_key(KeyCode::KeyJ);
assert!(!b.held_before_frame(&input));
}
#[test]
fn ron_round_trip_preserves_key_and_mouse_variants() {
let bindings = vec![
Binding::Key(KeyCode::Space),
Binding::Mouse(MouseButton::Left),
Binding::Key(KeyCode::ShiftLeft),
];
let s = ron::to_string(&bindings).unwrap();
let parsed: Vec<Binding> = ron::from_str(&s).unwrap();
assert_eq!(parsed, bindings);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Per-frame input — raw state, edges, and remappable named actions.
//!
//! Stage 7 builds the engine's input abstraction in three layers:
//!
//! 1. [`InputState`] (piece 1) — the per-frame snapshot of keyboard, mouse,
//! cursor, and scroll, with `pressed` / `released` edge detection and a
//! persistent `held` state. The windowing runner pumps raw `WindowEvent`s
//! into it and clears edges between frames; game/editor code reads it via
//! [`AppCtx::input`](crate::window::AppCtx::input).
//! 2. [`Binding`] + [`ActionMap`] (piece 2) — named actions like `"Jump"`
//! bound to one or more physical inputs, each carrying a **default**
//! binding and a (possibly remapped) **current** binding. Game code
//! queries actions by name, so a user-facing remap never touches game
//! code. Current bindings round-trip through RON for persistence
//! (typically via the [`Settings`](crate::settings::Settings) framework).
//! 3. [`AxisBinding`] + [`Axis2DBinding`] (piece 3) — 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.
//!
//! # Why edges and state are tracked separately
//!
//! Game logic typically wants three distinct things from a physical input:
//! the moment it became pressed (a jump fires once on key-down, never on
//! subsequent frames while held), the moment it was released (a charged
//! shot fires on key-up), and whether it is currently down (a sprint key
//! accelerates while held). Tracking all three explicitly makes the
//! semantics robust against OS key auto-repeat — a held key produces a
//! single `pressed` edge no matter how many times the OS re-sends the
//! event — and avoids the per-callsite bookkeeping every action would
//! otherwise need.
//!
//! # Quick reference
//!
//! ```
//! use oxide_engine::input::{ActionMap, Binding, InputState};
//! use oxide_engine::winit::keyboard::KeyCode;
//!
//! let mut input = InputState::new();
//! input.press_key(KeyCode::Space);
//! assert!(input.pressed(KeyCode::Space)); // edge — true only this frame
//! assert!(input.held(KeyCode::Space)); // state — true while held
//!
//! // Layer named actions on top — game code never names the physical key.
//! let mut actions = ActionMap::new();
//! actions.register("Jump", [Binding::Key(KeyCode::Space)]);
//! assert!(actions.action_pressed("Jump", &input));
//!
//! input.end_frame();
//! assert!(!input.pressed(KeyCode::Space)); // edge cleared
//! assert!(input.held(KeyCode::Space)); // held persists
//! ```
//!
//! # Synthesized-event API
//!
//! The mutators on [`InputState`] (`press_key`, `release_mouse`,
//! `set_cursor`, `add_mouse_delta`, `add_scroll`, `forget_cursor`,
//! `release_all_held`) are the same path `handle_event` uses, and are
//! intentionally public so tests can drive input directly without
//! constructing `winit` events (winit 0.30's `DeviceId` cannot be
//! fabricated outside an event loop, so most `WindowEvent` variants are
//! unreachable from synthesized events).
mod action;
mod axis;
mod binding;
mod state;
pub use action::{ActionMap, ActionOverrides};
pub use axis::{Axis2DBinding, AxisBinding};
pub use binding::Binding;
pub use state::InputState;
+472
View File
@@ -0,0 +1,472 @@
//! The per-frame [`InputState`] — keyboard, mouse, cursor, and scroll with
//! edge detection. The module-level documentation lives in
//! [`crate::input`](super); this file is the implementation.
use std::collections::HashSet;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
use crate::math::Vec2;
/// Pixels-per-line factor used to normalize trackpad pixel scroll deltas into
/// the same units as wheel-notch [`MouseScrollDelta::LineDelta`]. Matches the
/// convention the editor's orbit-camera zoom already uses, so behavior is
/// consistent whether the user has a mouse wheel or a touchpad.
const SCROLL_PIXELS_PER_LINE: f32 = 40.0;
/// Per-frame snapshot of keyboard, mouse, and pointer state.
///
/// Built up across the frame from raw events and queried by game / editor
/// code. All edge sets (pressed / released, mouse delta, scroll) are cleared
/// by [`end_frame`](Self::end_frame); held state and cursor position persist
/// across frames.
#[derive(Debug, Default, Clone)]
pub struct InputState {
keys_held: HashSet<KeyCode>,
keys_pressed: HashSet<KeyCode>,
keys_released: HashSet<KeyCode>,
mouse_held: HashSet<MouseButton>,
mouse_pressed: HashSet<MouseButton>,
mouse_released: HashSet<MouseButton>,
cursor: Option<Vec2>,
mouse_delta: Vec2,
scroll: Vec2,
}
impl InputState {
/// A new state with nothing pressed and no cursor known.
pub fn new() -> Self {
Self::default()
}
// --- Queries: keyboard -------------------------------------------------
/// `true` if `key` became pressed this frame (edge — true for exactly the
/// frame of the key-down, regardless of OS auto-repeat).
pub fn pressed(&self, key: KeyCode) -> bool {
self.keys_pressed.contains(&key)
}
/// `true` if `key` was released this frame (edge — true for exactly the
/// frame of the key-up).
pub fn released(&self, key: KeyCode) -> bool {
self.keys_released.contains(&key)
}
/// `true` if `key` is currently held down (state — true every frame until
/// the key-up arrives).
pub fn held(&self, key: KeyCode) -> bool {
self.keys_held.contains(&key)
}
/// All currently-held keys. Useful for debug overlays.
pub fn keys_held(&self) -> impl Iterator<Item = KeyCode> + '_ {
self.keys_held.iter().copied()
}
// --- Queries: mouse ----------------------------------------------------
/// `true` if `button` became pressed this frame (edge).
pub fn mouse_pressed(&self, button: MouseButton) -> bool {
self.mouse_pressed.contains(&button)
}
/// `true` if `button` was released this frame (edge).
pub fn mouse_released(&self, button: MouseButton) -> bool {
self.mouse_released.contains(&button)
}
/// `true` if `button` is currently held down (state).
pub fn mouse_held(&self, button: MouseButton) -> bool {
self.mouse_held.contains(&button)
}
/// All currently-held mouse buttons.
pub fn mouse_buttons_held(&self) -> impl Iterator<Item = MouseButton> + '_ {
self.mouse_held.iter().copied()
}
/// Current cursor position in physical pixels, or `None` if the cursor
/// has not entered the window yet (or just left it).
pub fn cursor(&self) -> Option<Vec2> {
self.cursor
}
/// Cursor movement since the last [`end_frame`](Self::end_frame), in
/// physical pixels. The first cursor event of a session (or after a
/// [`CursorLeft`](WindowEvent::CursorLeft)) seeds the position **without**
/// producing a delta, so consumers never see a phantom jump on the first
/// frame the cursor appears.
pub fn mouse_delta(&self) -> Vec2 {
self.mouse_delta
}
/// Scroll accumulated since the last [`end_frame`](Self::end_frame), in
/// line-equivalent units (pixel deltas are divided by a fixed pixels-per-
/// line constant so wheels and touchpads report on the same scale).
pub fn scroll(&self) -> Vec2 {
self.scroll
}
// --- Event pump --------------------------------------------------------
/// Folds one raw [`WindowEvent`] into the state.
///
/// Non-input events (resize, redraw, focus, …) are ignored, so the runner
/// can pump every event without filtering. Auto-repeat key-down events
/// from the OS do not re-fire the [`pressed`](Self::pressed) edge: a held
/// key only produces an edge on the first down.
pub fn handle_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event, .. } => {
if let PhysicalKey::Code(code) = event.physical_key {
match event.state {
ElementState::Pressed => self.press_key(code),
ElementState::Released => self.release_key(code),
}
}
}
WindowEvent::MouseInput { state, button, .. } => match state {
ElementState::Pressed => self.press_mouse(*button),
ElementState::Released => self.release_mouse(*button),
},
WindowEvent::CursorMoved { position, .. } => {
self.set_cursor(Vec2::new(position.x as f32, position.y as f32));
}
WindowEvent::CursorLeft { .. } => self.forget_cursor(),
WindowEvent::MouseWheel { delta, .. } => match delta {
MouseScrollDelta::LineDelta(x, y) => self.add_scroll(*x, *y),
MouseScrollDelta::PixelDelta(p) => self.add_scroll(
p.x as f32 / SCROLL_PIXELS_PER_LINE,
p.y as f32 / SCROLL_PIXELS_PER_LINE,
),
},
WindowEvent::Focused(false) => self.release_all_held(),
_ => {}
}
}
// --- Synthesized mutators (used by both handle_event and tests) --------
/// Records that `key` was pressed. The [`pressed`](Self::pressed) edge
/// fires only when the key was not already held, so OS auto-repeat does
/// not retrigger one-shot actions.
pub fn press_key(&mut self, key: KeyCode) {
if self.keys_held.insert(key) {
self.keys_pressed.insert(key);
}
}
/// Records that `key` was released. The [`released`](Self::released)
/// edge fires whether or not the key was previously tracked as held —
/// the OS occasionally sends a release without a matching press (e.g.
/// the window gained focus mid-press).
pub fn release_key(&mut self, key: KeyCode) {
self.keys_held.remove(&key);
self.keys_released.insert(key);
}
/// Records that `button` was pressed (with the same edge semantics as
/// [`press_key`]).
pub fn press_mouse(&mut self, button: MouseButton) {
if self.mouse_held.insert(button) {
self.mouse_pressed.insert(button);
}
}
/// Records that `button` was released.
pub fn release_mouse(&mut self, button: MouseButton) {
self.mouse_held.remove(&button);
self.mouse_released.insert(button);
}
/// Sets the cursor position. The delta is accumulated **only** relative
/// to a previously-known cursor; the very first set (or the first set
/// after a [`CursorLeft`](WindowEvent::CursorLeft) event) seeds the
/// position without contributing to [`mouse_delta`](Self::mouse_delta).
pub fn set_cursor(&mut self, position: Vec2) {
if let Some(prev) = self.cursor {
self.mouse_delta += position - prev;
}
self.cursor = Some(position);
}
/// Adds a raw mouse delta in physical pixels. Useful for relative-motion
/// sources (`DeviceEvent::MouseMotion`, future pointer-lock) and for tests.
pub fn add_mouse_delta(&mut self, dx: f32, dy: f32) {
self.mouse_delta += Vec2::new(dx, dy);
}
/// Adds a scroll increment in line-equivalent units.
pub fn add_scroll(&mut self, x: f32, y: f32) {
self.scroll += Vec2::new(x, y);
}
// --- Frame boundary ----------------------------------------------------
/// Clears per-frame edge state and accumulated deltas; held state and
/// cursor position persist. The runner calls this after game logic has
/// read the edges for the current frame.
pub fn end_frame(&mut self) {
self.keys_pressed.clear();
self.keys_released.clear();
self.mouse_pressed.clear();
self.mouse_released.clear();
self.mouse_delta = Vec2::ZERO;
self.scroll = Vec2::ZERO;
}
/// Forgets the cursor anchor so the next [`set_cursor`](Self::set_cursor)
/// re-seeds without producing a phantom delta. The event pump calls this
/// on [`CursorLeft`](WindowEvent::CursorLeft); the public exposure lets
/// hosts that drive `InputState` directly (e.g. tests, or a future
/// pointer-lock toggle) re-anchor without simulating a window event.
pub fn forget_cursor(&mut self) {
self.cursor = None;
}
/// Releases every currently-held key and mouse button (firing each
/// `released` edge once). The event pump calls this when the window
/// loses focus, since the OS will never deliver the matching releases
/// for keys held at that moment, and stuck-key bugs would otherwise
/// follow the window across alt-tab cycles.
pub fn release_all_held(&mut self) {
for key in self.keys_held.drain() {
self.keys_released.insert(key);
}
for button in self.mouse_held.drain() {
self.mouse_released.insert(button);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_press_sets_edge_and_state() {
let mut input = InputState::new();
input.press_key(KeyCode::Space);
assert!(input.pressed(KeyCode::Space));
assert!(input.held(KeyCode::Space));
assert!(!input.released(KeyCode::Space));
}
#[test]
fn end_frame_clears_edges_but_not_held() {
let mut input = InputState::new();
input.press_key(KeyCode::Space);
input.end_frame();
assert!(!input.pressed(KeyCode::Space), "edge must clear");
assert!(input.held(KeyCode::Space), "state must persist");
}
#[test]
fn key_release_sets_edge_and_clears_held() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyA);
input.end_frame();
input.release_key(KeyCode::KeyA);
assert!(input.released(KeyCode::KeyA));
assert!(!input.held(KeyCode::KeyA));
assert!(!input.pressed(KeyCode::KeyA));
}
#[test]
fn os_auto_repeat_does_not_refire_pressed_edge() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyW);
input.end_frame(); // pressed edge consumed
// The OS resends Pressed for the same key while it's held.
input.press_key(KeyCode::KeyW);
assert!(
!input.pressed(KeyCode::KeyW),
"auto-repeat must not retrigger pressed"
);
assert!(input.held(KeyCode::KeyW));
}
#[test]
fn release_without_prior_press_still_emits_edge() {
// The OS occasionally delivers a release with no matching press (e.g.
// window focused mid-press). The released edge still fires so consumers
// can react.
let mut input = InputState::new();
input.release_key(KeyCode::Escape);
assert!(input.released(KeyCode::Escape));
assert!(!input.held(KeyCode::Escape));
}
#[test]
fn pressed_and_released_in_same_frame_both_fire() {
// Within a single frame a quick tap should register both edges so
// logic that wants a "click on release" pattern is reachable from
// the synthesized input path.
let mut input = InputState::new();
input.press_key(KeyCode::Enter);
input.release_key(KeyCode::Enter);
assert!(input.pressed(KeyCode::Enter));
assert!(input.released(KeyCode::Enter));
assert!(!input.held(KeyCode::Enter));
}
#[test]
fn mouse_button_edges_parallel_keyboard() {
let mut input = InputState::new();
input.press_mouse(MouseButton::Left);
assert!(input.mouse_pressed(MouseButton::Left));
assert!(input.mouse_held(MouseButton::Left));
input.end_frame();
assert!(!input.mouse_pressed(MouseButton::Left));
assert!(input.mouse_held(MouseButton::Left));
input.release_mouse(MouseButton::Left);
assert!(input.mouse_released(MouseButton::Left));
assert!(!input.mouse_held(MouseButton::Left));
}
#[test]
fn first_cursor_move_produces_no_delta() {
let mut input = InputState::new();
input.set_cursor(Vec2::new(100.0, 200.0));
assert_eq!(input.mouse_delta(), Vec2::ZERO);
assert_eq!(input.cursor(), Some(Vec2::new(100.0, 200.0)));
}
#[test]
fn subsequent_cursor_moves_accumulate_delta() {
let mut input = InputState::new();
input.set_cursor(Vec2::new(100.0, 200.0));
input.set_cursor(Vec2::new(110.0, 195.0));
input.set_cursor(Vec2::new(115.0, 190.0));
// (110-100) + (115-110), (195-200) + (190-195) = (15, -10)
assert_eq!(input.mouse_delta(), Vec2::new(15.0, -10.0));
}
#[test]
fn end_frame_resets_delta_but_preserves_cursor() {
let mut input = InputState::new();
input.set_cursor(Vec2::new(0.0, 0.0));
input.set_cursor(Vec2::new(10.0, 10.0));
input.end_frame();
assert_eq!(input.mouse_delta(), Vec2::ZERO);
assert_eq!(input.cursor(), Some(Vec2::new(10.0, 10.0)));
// Next move accumulates from the persisted cursor, not from zero.
input.set_cursor(Vec2::new(13.0, 11.0));
assert_eq!(input.mouse_delta(), Vec2::new(3.0, 1.0));
}
#[test]
fn add_mouse_delta_layers_on_top_of_cursor_motion() {
let mut input = InputState::new();
input.set_cursor(Vec2::new(0.0, 0.0));
input.set_cursor(Vec2::new(5.0, 0.0));
input.add_mouse_delta(2.0, 3.0); // e.g. raw DeviceEvent motion
assert_eq!(input.mouse_delta(), Vec2::new(7.0, 3.0));
}
#[test]
fn scroll_accumulates_and_resets() {
let mut input = InputState::new();
input.add_scroll(0.0, 1.0);
input.add_scroll(0.0, 2.5);
assert_eq!(input.scroll(), Vec2::new(0.0, 3.5));
input.end_frame();
assert_eq!(input.scroll(), Vec2::ZERO);
}
#[test]
fn focus_loss_via_handle_event_releases_held() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyW);
input.press_mouse(MouseButton::Left);
input.end_frame();
// Focused(false) is one of the WindowEvent variants with no DeviceId,
// so the routing through handle_event itself is exercised here.
input.handle_event(&WindowEvent::Focused(false));
assert!(!input.held(KeyCode::KeyW), "key must not stay stuck");
assert!(!input.mouse_held(MouseButton::Left));
assert!(input.released(KeyCode::KeyW));
assert!(input.mouse_released(MouseButton::Left));
}
#[test]
fn release_all_held_drops_state_and_fires_edges() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyW);
input.press_key(KeyCode::ShiftLeft);
input.press_mouse(MouseButton::Right);
input.end_frame();
input.release_all_held();
assert!(!input.held(KeyCode::KeyW));
assert!(!input.held(KeyCode::ShiftLeft));
assert!(!input.mouse_held(MouseButton::Right));
assert!(input.released(KeyCode::KeyW));
assert!(input.released(KeyCode::ShiftLeft));
assert!(input.mouse_released(MouseButton::Right));
}
#[test]
fn forget_cursor_resets_anchor_so_next_move_has_no_delta() {
let mut input = InputState::new();
input.set_cursor(Vec2::new(0.0, 0.0));
input.set_cursor(Vec2::new(10.0, 10.0));
input.end_frame();
input.forget_cursor();
assert!(input.cursor().is_none());
// First move back in reseeds without contributing a delta.
input.set_cursor(Vec2::new(200.0, 50.0));
assert_eq!(input.mouse_delta(), Vec2::ZERO);
assert_eq!(input.cursor(), Some(Vec2::new(200.0, 50.0)));
}
#[test]
fn handle_event_ignores_unrelated_window_events() {
// These three WindowEvent variants don't carry a DeviceId, so they
// can be constructed in tests — the routing through handle_event is
// exercised end-to-end here.
let mut input = InputState::new();
input.press_key(KeyCode::Space);
input.handle_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
800, 600,
)));
input.handle_event(&WindowEvent::CloseRequested);
input.handle_event(&WindowEvent::RedrawRequested);
assert!(input.pressed(KeyCode::Space));
assert!(input.held(KeyCode::Space));
}
#[test]
fn keys_held_iterates_currently_held_keys() {
let mut input = InputState::new();
input.press_key(KeyCode::KeyW);
input.press_key(KeyCode::KeyA);
input.release_key(KeyCode::KeyA);
let held: HashSet<KeyCode> = input.keys_held().collect();
assert_eq!(held, HashSet::from([KeyCode::KeyW]));
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Per-entity [`Layer`] membership and gameplay [`Tags`].
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::LayerMask;
/// Component: the **single** layer an entity is on.
///
/// Each entity belongs to exactly one of 32 logical layers (index `0..32`).
/// Filters elsewhere — a camera's visibility mask, a physics collision filter,
/// a raycast's layer filter — carry [`LayerMask`]s and select an entity by
/// testing `mask.contains_layer(entity.layer.index)` (see [`Self::matches`]).
///
/// This matches the Unity model: **per-entity membership is single, filters
/// are masks.** If you need an entity to be "in" multiple categories
/// simultaneously, use [`Tags`] (gameplay tags) — tagging is the multi-valued
/// concept; layers are the single-valued one.
///
/// Every freshly spawned entity is on [`DEFAULT`](Self::DEFAULT) (the layer
/// named `"Default"` at index 0) unless changed, so it's visible to "see
/// everything" filters out of the box.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, crate::reflect::Reflect,
)]
pub struct Layer {
/// Layer index (`0..32`). Use the
/// [`LayerRegistry`](super::LayerRegistry) to translate between this and a
/// human-readable name.
pub index: u32,
}
impl Layer {
/// The "Default" layer (index 0). Every freshly spawned entity starts here.
pub const DEFAULT: Layer = Layer { index: 0 };
/// Builds a `Layer` on the given `index` (`0..32`).
pub const fn on(index: u32) -> Self {
Self { index }
}
/// Whether this layer is selected by the given filter mask.
pub const fn matches(self, filter: LayerMask) -> bool {
filter.contains_layer(self.index)
}
/// A [`LayerMask`] containing exactly this layer — useful when an API
/// expects a mask (e.g. a one-layer camera visibility filter).
pub const fn mask(self) -> LayerMask {
LayerMask::layer(self.index)
}
}
impl Default for Layer {
fn default() -> Self {
Layer::DEFAULT
}
}
/// Component: free-form gameplay tags on an entity.
///
/// Tags are the lightweight, string-keyed counterpart to [`Layer`]. Where a
/// [`LayerMask`] is a fixed 32-slot bitset for hot-path *filtering*, tags are an
/// open-ended set for *identification* — `"Enemy"`, `"Interactable"`,
/// `"Checkpoint"` — that game code and scripts query by name. Stored sorted so
/// serialization is deterministic.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tags(BTreeSet<String>);
impl Tags {
/// An empty tag set.
pub fn new() -> Self {
Self::default()
}
/// A tag set containing the single tag `tag`.
pub fn single(tag: impl Into<String>) -> Self {
let mut set = BTreeSet::new();
set.insert(tag.into());
Tags(set)
}
/// Adds `tag`. Returns `true` if it was not already present.
pub fn insert(&mut self, tag: impl Into<String>) -> bool {
self.0.insert(tag.into())
}
/// Removes `tag`. Returns `true` if it was present.
pub fn remove(&mut self, tag: &str) -> bool {
self.0.remove(tag)
}
/// Whether `tag` is present.
pub fn contains(&self, tag: &str) -> bool {
self.0.contains(tag)
}
/// Iterates the tags in sorted order.
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(String::as_str)
}
/// The number of tags.
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether there are no tags.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl<S: Into<String>> FromIterator<S> for Tags {
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
Tags(iter.into_iter().map(Into::into).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_layer_is_zero() {
let l = Layer::default();
assert_eq!(l.index, 0);
// A "see everything" filter selects a default entity.
assert!(l.matches(LayerMask::ALL));
}
#[test]
fn layer_matches_filter_when_index_is_in_the_mask() {
let on_npc = Layer::on(2);
let npc_or_player = LayerMask::NONE.with(1).with(2);
assert!(on_npc.matches(npc_or_player));
assert!(!on_npc.matches(LayerMask::layer(5)));
assert_eq!(on_npc.mask(), LayerMask::layer(2));
}
#[test]
fn layer_round_trips_through_ron() {
let l = Layer::on(7);
let ron = ron::to_string(&l).unwrap();
let back: Layer = ron::from_str(&ron).unwrap();
assert_eq!(l, back);
}
#[test]
fn tags_insert_remove_contains() {
let mut tags = Tags::new();
assert!(tags.insert("Enemy"));
assert!(!tags.insert("Enemy")); // already present
assert!(tags.insert("Flying"));
assert!(tags.contains("Enemy"));
assert_eq!(tags.len(), 2);
assert!(tags.remove("Enemy"));
assert!(!tags.contains("Enemy"));
assert!(!tags.remove("Enemy"));
}
#[test]
fn tags_iterate_sorted_and_round_trip() {
let tags: Tags = ["Zebra", "Apple", "Mango"].into_iter().collect();
assert_eq!(
tags.iter().collect::<Vec<_>>(),
vec!["Apple", "Mango", "Zebra"]
);
let ron = ron::to_string(&tags).unwrap();
let back: Tags = ron::from_str(&ron).unwrap();
assert_eq!(tags, back);
}
}
+115
View File
@@ -0,0 +1,115 @@
//! [`GroupRegistry`]: project-defined gameplay group names.
//!
//! Groups are the **multi-valued** counterpart to the single-valued
//! [`Layer`](super::Layer). Where an entity is on exactly one layer (its
//! render/physics filter slot), it can belong to *any number* of groups —
//! `"Enemies"`, `"Interactables"`, `"SaveOnExit"` — which game code and scripts
//! query by name. This mirrors the Unity model: one Layer + many tags/groups.
//!
//! Per-entity membership is stored in the [`Tags`](super::Tags) component. The
//! registry is the project-level list of *which group names exist*, so the
//! editor can offer a fixed set to pick from (predefined, not free-typed) and a
//! team shares one vocabulary. Defining or deleting a group only changes that
//! vocabulary; it never touches the tags already on entities (a deleted group
//! simply becomes an "ungrouped" tag until removed).
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
/// The set of project-defined group names.
///
/// Stored sorted (a [`BTreeSet`]) so the editor's dropdown order and serialized
/// form are deterministic. Names are the identity used in data and UI, so they
/// should be stable across a project's life.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GroupRegistry {
names: BTreeSet<String>,
}
impl GroupRegistry {
/// An empty registry — no groups defined yet.
pub fn new() -> Self {
Self::default()
}
/// Defines `name` as a group. Returns `true` if it was newly added.
pub fn define(&mut self, name: impl Into<String>) -> bool {
self.names.insert(name.into())
}
/// Removes `name` from the defined groups. Returns `true` if it existed.
///
/// Entities already tagged with `name` keep the tag — only the project's
/// list of valid groups shrinks.
pub fn undefine(&mut self, name: &str) -> bool {
self.names.remove(name)
}
/// Whether `name` is a defined group.
pub fn contains(&self, name: &str) -> bool {
self.names.contains(name)
}
/// Iterates the defined group names in sorted order.
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.names.iter().map(String::as_str)
}
/// The number of defined groups.
pub fn len(&self) -> usize {
self.names.len()
}
/// Whether no groups are defined.
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
impl<S: Into<String>> FromIterator<S> for GroupRegistry {
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
GroupRegistry {
names: iter.into_iter().map(Into::into).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn define_is_idempotent_and_reports_newness() {
let mut reg = GroupRegistry::new();
assert!(reg.is_empty());
assert!(reg.define("Enemies"));
assert!(!reg.define("Enemies")); // already defined
assert!(reg.define("Pickups"));
assert!(reg.contains("Enemies"));
assert_eq!(reg.len(), 2);
}
#[test]
fn undefine_removes_only_from_the_vocabulary() {
let mut reg: GroupRegistry = ["Enemies", "Pickups"].into_iter().collect();
assert!(reg.undefine("Enemies"));
assert!(!reg.undefine("Enemies"));
assert!(!reg.contains("Enemies"));
assert!(reg.contains("Pickups"));
}
#[test]
fn iter_is_sorted() {
let reg: GroupRegistry = ["Zed", "Alpha", "Mid"].into_iter().collect();
assert_eq!(reg.iter().collect::<Vec<_>>(), vec!["Alpha", "Mid", "Zed"]);
}
#[test]
fn round_trips_through_ron() {
let reg: GroupRegistry = ["Enemies", "Interactables"].into_iter().collect();
let ron = ron::to_string(&reg).unwrap();
let back: GroupRegistry = ron::from_str(&ron).unwrap();
assert_eq!(reg, back);
}
}
+287
View File
@@ -0,0 +1,287 @@
//! [`LayerMask`]: a 32-slot bitset used to include/exclude entities.
use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, BitXor, BitXorAssign, Not};
use serde::{Deserialize, Serialize};
/// The number of distinct layers a [`LayerMask`] can represent.
///
/// Fixed at 32 so a mask is a single `u32` — cheap to copy, store on a
/// component, and test in hot paths (physics filtering, render visibility,
/// scene queries).
pub const MAX_LAYERS: u32 = 32;
/// A set of layers, packed into the bits of a `u32`.
///
/// A `LayerMask` is the one shared primitive behind every "which layers does
/// this interact with?" question in the engine. It plays two roles:
///
/// - **Membership** — the layers an entity *belongs to* (see
/// [`Layer`](super::Layer)).
/// - **Filter** — the layers a camera, query, or collision rule *cares about*.
///
/// Two masks interact when they share any layer: [`intersects`](Self::intersects)
/// is the universal test (`(a & b) != 0`). Layer indices run `0..32`; passing an
/// index `>= 32` panics (in every build), catching mistakes early rather than
/// silently wrapping.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LayerMask(u32);
impl LayerMask {
/// The empty mask — interacts with nothing.
pub const NONE: LayerMask = LayerMask(0);
/// Every layer set — interacts with everything.
pub const ALL: LayerMask = LayerMask(u32::MAX);
/// A mask from a raw bit pattern.
pub const fn from_bits(bits: u32) -> Self {
LayerMask(bits)
}
/// The raw bit pattern.
pub const fn bits(self) -> u32 {
self.0
}
/// A mask containing only the single layer `index` (`0..32`).
///
/// # Panics
/// Panics if `index >= 32`.
pub const fn layer(index: u32) -> Self {
assert!(
index < MAX_LAYERS,
"layer index out of range (must be 0..32)"
);
LayerMask(1u32 << index)
}
/// This mask with layer `index` added.
pub const fn with(self, index: u32) -> Self {
LayerMask(self.0 | Self::layer(index).0)
}
/// This mask with layer `index` removed.
pub const fn without(self, index: u32) -> Self {
LayerMask(self.0 & !Self::layer(index).0)
}
/// This mask with layer `index` flipped.
pub const fn toggled(self, index: u32) -> Self {
LayerMask(self.0 ^ Self::layer(index).0)
}
/// Whether layer `index` is present.
///
/// # Panics
/// Panics if `index >= 32`.
pub const fn contains_layer(self, index: u32) -> bool {
self.0 & Self::layer(index).0 != 0
}
/// Whether this mask and `other` share at least one layer.
///
/// This is the canonical interaction test — a body on the masks it belongs
/// to "interacts with" a filter that selects any of those layers.
pub const fn intersects(self, other: LayerMask) -> bool {
self.0 & other.0 != 0
}
/// Whether every layer in `other` is also in this mask.
pub const fn contains(self, other: LayerMask) -> bool {
self.0 & other.0 == other.0
}
/// The union (bitwise OR) of two masks.
pub const fn union(self, other: LayerMask) -> Self {
LayerMask(self.0 | other.0)
}
/// The intersection (bitwise AND) of two masks.
pub const fn intersection(self, other: LayerMask) -> Self {
LayerMask(self.0 & other.0)
}
/// The layers in this mask that are not in `other`.
pub const fn difference(self, other: LayerMask) -> Self {
LayerMask(self.0 & !other.0)
}
/// The complement — every layer not in this mask.
pub const fn complement(self) -> Self {
LayerMask(!self.0)
}
/// Whether no layers are set.
pub const fn is_empty(self) -> bool {
self.0 == 0
}
/// The number of layers set.
pub const fn len(self) -> u32 {
self.0.count_ones()
}
/// Iterates the indices (`0..32`) of the set layers, ascending.
pub fn iter(self) -> impl Iterator<Item = u32> {
(0..MAX_LAYERS).filter(move |&i| self.0 & (1u32 << i) != 0)
}
}
impl Default for LayerMask {
/// The empty mask. Filters that should default to "see everything" must opt
/// into [`LayerMask::ALL`] explicitly rather than rely on this.
fn default() -> Self {
LayerMask::NONE
}
}
impl FromIterator<u32> for LayerMask {
/// Builds a mask from layer indices. Each index must be `0..32`.
fn from_iter<I: IntoIterator<Item = u32>>(iter: I) -> Self {
iter.into_iter().fold(LayerMask::NONE, LayerMask::with)
}
}
impl BitOr for LayerMask {
type Output = LayerMask;
fn bitor(self, rhs: LayerMask) -> LayerMask {
self.union(rhs)
}
}
impl BitOrAssign for LayerMask {
fn bitor_assign(&mut self, rhs: LayerMask) {
self.0 |= rhs.0;
}
}
impl BitAnd for LayerMask {
type Output = LayerMask;
fn bitand(self, rhs: LayerMask) -> LayerMask {
self.intersection(rhs)
}
}
impl BitAndAssign for LayerMask {
fn bitand_assign(&mut self, rhs: LayerMask) {
self.0 &= rhs.0;
}
}
impl BitXor for LayerMask {
type Output = LayerMask;
fn bitxor(self, rhs: LayerMask) -> LayerMask {
LayerMask(self.0 ^ rhs.0)
}
}
impl BitXorAssign for LayerMask {
fn bitxor_assign(&mut self, rhs: LayerMask) {
self.0 ^= rhs.0;
}
}
impl Not for LayerMask {
type Output = LayerMask;
fn not(self) -> LayerMask {
self.complement()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_sets_a_single_bit() {
assert_eq!(LayerMask::layer(0).bits(), 0b1);
assert_eq!(LayerMask::layer(3).bits(), 0b1000);
assert_eq!(LayerMask::layer(31).bits(), 1 << 31);
}
#[test]
#[should_panic]
fn layer_index_out_of_range_panics() {
let _ = LayerMask::layer(32);
}
#[test]
fn builders_add_and_remove_layers() {
let m = LayerMask::NONE.with(1).with(4);
assert!(m.contains_layer(1));
assert!(m.contains_layer(4));
assert!(!m.contains_layer(0));
assert_eq!(m.len(), 2);
let m = m.without(1);
assert!(!m.contains_layer(1));
assert!(m.contains_layer(4));
let m = m.toggled(4).toggled(7);
assert!(!m.contains_layer(4));
assert!(m.contains_layer(7));
}
#[test]
fn intersects_is_the_interaction_test() {
let player = LayerMask::layer(1);
let npc = LayerMask::layer(2);
// A trigger that only fires for the player or npc layers.
let trigger_filter = player.union(npc);
assert!(player.intersects(trigger_filter));
assert!(npc.intersects(trigger_filter));
// A wall on layer 5 does not trip the trigger.
assert!(!LayerMask::layer(5).intersects(trigger_filter));
}
#[test]
fn set_algebra() {
let a = LayerMask::NONE.with(0).with(1).with(2);
let b = LayerMask::NONE.with(1).with(2).with(3);
assert_eq!(a.union(b), LayerMask::NONE.with(0).with(1).with(2).with(3));
assert_eq!(a.intersection(b), LayerMask::NONE.with(1).with(2));
assert_eq!(a.difference(b), LayerMask::layer(0));
assert!(a.contains(LayerMask::NONE.with(0).with(1)));
assert!(!a.contains(b));
assert_eq!(LayerMask::ALL.complement(), LayerMask::NONE);
}
#[test]
fn bit_operators_match_named_methods() {
let a = LayerMask::layer(1);
let b = LayerMask::layer(2);
assert_eq!(a | b, a.union(b));
assert_eq!((a | b) & a, a);
assert_eq!(a ^ a, LayerMask::NONE);
assert_eq!(!LayerMask::NONE, LayerMask::ALL);
let mut m = LayerMask::NONE;
m |= a;
m |= b;
assert!(m.intersects(a) && m.intersects(b));
m &= a;
assert_eq!(m, a);
}
#[test]
fn iter_yields_ascending_indices() {
let m = LayerMask::NONE.with(0).with(5).with(31);
assert_eq!(m.iter().collect::<Vec<_>>(), vec![0, 5, 31]);
assert!(LayerMask::NONE.iter().next().is_none());
}
#[test]
fn from_iter_collects_indices() {
let m: LayerMask = [1u32, 3, 5].into_iter().collect();
assert_eq!(m, LayerMask::NONE.with(1).with(3).with(5));
}
#[test]
fn round_trips_through_ron() {
let m = LayerMask::NONE.with(2).with(9).with(30);
let ron = ron::to_string(&m).unwrap();
let back: LayerMask = ron::from_str(&ron).unwrap();
assert_eq!(m, back);
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Layer & tags — the engine's filtering primitives.
//!
//! Stage 5 introduces one shared way to answer "which things interact with
//! which?", so physics, rendering, and scene queries all speak the same
//! language instead of each inventing its own:
//!
//! - [`LayerMask`] — a 32-slot bitset. The single primitive used both for an
//! entity's **membership** and for the **filters** that select entities.
//! Two masks interact when they share any layer ([`LayerMask::intersects`]).
//! - [`LayerRegistry`] — project-level human-readable names for the 32 layers
//! (e.g. layer 1 = `"Player"`), so masks can be authored and displayed by
//! name. Layer 0 is `"Default"`.
//! - [`Layer`] — the per-entity component holding its membership mask. Defaults
//! to the `Default` layer so new entities are visible to broad filters.
//! - [`Tags`] — a per-entity set of free-form string tags for gameplay
//! *identification* (`"Enemy"`, `"Interactable"`), distinct from the
//! hot-path [`LayerMask`]. This is the **multi-valued** membership concept
//! (an entity is in many groups) paired with the single-valued [`Layer`].
//! - [`GroupRegistry`] — project-level list of defined group names, so the
//! editor offers a fixed vocabulary to tag entities with (predefined, like
//! layers) rather than free-typed strings.
//!
//! How consumers use it (built out in later stages):
//! - **Physics** (Stage 9): a collider's membership + filter masks drive
//! collision groups and sensor/trigger filtering.
//! - **Rendering** (Stage 5 pipeline): a camera holds a visibility filter; only
//! entities whose [`Layer`] intersect it are drawn.
//! - **Scene queries**: a raycast carries a filter mask tested against
//! candidate entities' membership.
//!
//! All four types are serializable, so layer data is dual-editable (editor +
//! scripts/AI) like every other engine component.
mod components;
mod groups;
mod mask;
mod registry;
pub use components::{Layer, Tags};
pub use groups::GroupRegistry;
pub use mask::{LayerMask, MAX_LAYERS};
pub use registry::LayerRegistry;
+163
View File
@@ -0,0 +1,163 @@
//! [`LayerRegistry`]: human-readable names for the 32 layers.
use serde::{Deserialize, Serialize};
use super::{LayerMask, MAX_LAYERS};
/// Maps layer indices (`0..32`) to project-defined names.
///
/// A [`LayerMask`] is just bits; the registry is what lets a project, the
/// editor, and scripts talk about layer **3** as `"Enemy"` instead of a magic
/// number. It is project-level data (serialized with the project, later stages)
/// and changing a name never moves an entity between layers — only the label
/// changes.
///
/// Index `0` is seeded with the name `"Default"`, the layer every entity starts
/// on (see [`Layer`](super::Layer)). The remaining slots are unnamed until a
/// project assigns them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LayerRegistry {
/// One slot per layer; `None` means the layer has no assigned name.
names: Vec<Option<String>>,
}
impl LayerRegistry {
/// A registry with only layer 0 named (`"Default"`).
pub fn new() -> Self {
let mut names = vec![None; MAX_LAYERS as usize];
names[0] = Some("Default".to_string());
Self { names }
}
/// Assigns `name` to layer `index`.
///
/// # Panics
/// Panics if `index >= 32`.
pub fn set(&mut self, index: u32, name: impl Into<String>) {
assert!(
index < MAX_LAYERS,
"layer index out of range (must be 0..32)"
);
self.names[index as usize] = Some(name.into());
}
/// Clears the name of layer `index`, leaving it unnamed.
///
/// # Panics
/// Panics if `index >= 32`.
pub fn clear(&mut self, index: u32) {
assert!(
index < MAX_LAYERS,
"layer index out of range (must be 0..32)"
);
self.names[index as usize] = None;
}
/// The name of layer `index`, or `None` if it is out of range or unnamed.
pub fn name(&self, index: u32) -> Option<&str> {
self.names.get(index as usize).and_then(|n| n.as_deref())
}
/// The index of the layer named `name`, or `None` if no layer has it.
///
/// Names are not required to be unique; the lowest matching index wins.
pub fn index_of(&self, name: &str) -> Option<u32> {
self.names
.iter()
.position(|n| n.as_deref() == Some(name))
.map(|i| i as u32)
}
/// A [`LayerMask`] built from layer names, skipping any that are unknown.
///
/// Convenient for authoring filters by name, e.g.
/// `registry.mask_of(["Player", "NPC"])`.
pub fn mask_of<'a, I>(&self, names: I) -> LayerMask
where
I: IntoIterator<Item = &'a str>,
{
names.into_iter().filter_map(|n| self.index_of(n)).collect()
}
/// Iterates `(index, name)` for every *named* layer, ascending by index.
pub fn iter(&self) -> impl Iterator<Item = (u32, &str)> {
self.names
.iter()
.enumerate()
.filter_map(|(i, n)| n.as_deref().map(|name| (i as u32, name)))
}
/// The number of named layers.
pub fn named_count(&self) -> usize {
self.names.iter().filter(|n| n.is_some()).count()
}
}
impl Default for LayerRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_names_only_layer_zero() {
let reg = LayerRegistry::new();
assert_eq!(reg.name(0), Some("Default"));
assert_eq!(reg.name(1), None);
assert_eq!(reg.named_count(), 1);
}
#[test]
fn set_and_look_up_by_name() {
let mut reg = LayerRegistry::new();
reg.set(1, "Player");
reg.set(2, "NPC");
reg.set(5, "Water");
assert_eq!(reg.name(2), Some("NPC"));
assert_eq!(reg.index_of("Water"), Some(5));
assert_eq!(reg.index_of("Missing"), None);
assert_eq!(reg.named_count(), 4);
}
#[test]
fn mask_of_names_builds_a_filter() {
let mut reg = LayerRegistry::new();
reg.set(1, "Player");
reg.set(2, "NPC");
let mask = reg.mask_of(["Player", "NPC", "Unknown"]);
assert_eq!(mask, LayerMask::NONE.with(1).with(2));
}
#[test]
fn clear_unsets_a_name() {
let mut reg = LayerRegistry::new();
reg.set(3, "Trigger");
assert_eq!(reg.index_of("Trigger"), Some(3));
reg.clear(3);
assert_eq!(reg.name(3), None);
assert_eq!(reg.index_of("Trigger"), None);
}
#[test]
fn iter_visits_named_layers_in_order() {
let mut reg = LayerRegistry::new();
reg.set(4, "B");
reg.set(2, "A");
let pairs: Vec<_> = reg.iter().collect();
assert_eq!(pairs, vec![(0, "Default"), (2, "A"), (4, "B")]);
}
#[test]
fn round_trips_through_ron() {
let mut reg = LayerRegistry::new();
reg.set(1, "Player");
reg.set(7, "Foliage");
let ron = ron::to_string(&reg).unwrap();
let back: LayerRegistry = ron::from_str(&ron).unwrap();
assert_eq!(reg, back);
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Oxide Engine — core library.
//!
//! Each system lives in its own module and is independently usable.
//! Systems are enabled progressively as stages are completed.
#![deny(warnings)]
// Lets `#[derive(Reflect)]` emit `::oxide_engine::reflect::…` paths that
// resolve even when the derive is used *inside* this crate (e.g. on the
// engine's own component types). Standard proc-macro self-reference trick.
extern crate self as oxide_engine;
pub mod app;
pub mod asset;
pub mod input;
pub mod layer;
pub mod math;
pub mod prefab;
pub mod project;
pub mod reflect;
pub mod render;
pub mod scene;
pub mod settings;
pub mod ui;
pub mod watch;
pub mod window;
// Re-exported so engine consumers can use GPU/windowing/ECS types without
// declaring (and version-matching) their own direct dependency.
pub use hecs;
pub use wgpu;
pub use winit;
pub mod prelude {
//! Common imports for engine consumers.
//!
//! The core engine container ([`App`](crate::app::App)) and the windowing
//! event-handler trait ([`WindowApp`](crate::window::WindowApp)) both live
//! here — they cover different roles and no longer share a name (Stage 6
//! resolved the Stage-5 naming clash).
pub use crate::app::{App, DefaultModules, Module, Schedule};
pub use crate::asset::{
load_gltf, AssetDatabase, AssetKind, AssetRef, AssetServer, AssetUid, GltfModel, Handle,
};
pub use crate::input::{
ActionMap, ActionOverrides, Axis2DBinding, AxisBinding, Binding, InputState,
};
pub use crate::layer::{GroupRegistry, Layer, LayerMask, LayerRegistry, Tags};
pub use crate::math::{
Aabb, Color, EulerRot, Frustum, Mat3, Mat4, Plane, Quat, Range3, Ray, Rect, Transform,
Vec2, Vec3, Vec4,
};
pub use crate::prefab::{ComponentSpec, Prefab, PrefabRegistry};
pub use crate::project::{Project, RecentProjects};
pub use crate::reflect::TypeRegistry;
pub use crate::render::{
Camera, ClearPass, DirectionalLight, ForwardPass, ForwardRenderer, FrameContext, Gpu,
GpuMesh, Lighting, Material, Mesh, MeshRenderer, PrimitiveShape, RenderContext,
RenderObject, RenderPass, RenderPipeline, UiBatch, UiOverlayPass, Vertex,
};
pub use crate::scene::{DespawnPolicy, Entity, Node, Scene, SceneError, SceneSnapshot};
pub use crate::settings::Settings;
pub use crate::ui::hit_test as ui_hit_test;
pub use crate::ui::{
layout as ui_layout, paint as ui_paint, shape as ui_shape, shape_runs as ui_shape_runs,
Align as UiAlign, Anchor as UiAnchor, AnchorGroup as UiAnchorGroup,
AtlasEntry as UiAtlasEntry, Border as UiBorder, DrawCommand as UiDrawCommand,
Font as UiFont, FontId as UiFontId, FontRef as UiFontRef, FontStore as UiFontStore,
FontWeight as UiFontWeight, GlyphAtlas as UiGlyphAtlas, GlyphId as UiGlyphId,
GlyphKey as UiGlyphKey, Grid as UiGrid, Insets as UiInsets, LayoutNode as UiLayoutNode,
LayoutStyle as UiLayoutStyle, LayoutTree as UiLayoutTree, PaintedFrame as UiPaintedFrame,
Router as UiRouter, RouterEvent as UiRouterEvent, RouterFrame as UiRouterFrame,
ShapeParams as UiShapeParams, ShapedGlyph as UiShapedGlyph, ShapedLine as UiShapedLine,
ShapedText as UiShapedText, Sizing as UiSizing, Stack as UiStack,
StackDirection as UiStackDirection, TextAlign as UiTextAlign, TextRun as UiTextRun,
TextStyle as UiTextStyle, Theme as UiTheme, UiPanel, VisualStyle as UiVisualStyle, Widget,
WidgetId, WidgetKind, WidgetPath, WidgetValue,
};
pub use crate::watch::{reload_changed_assets, ChangeEvent, ChangeKind, FileWatcher};
pub use crate::window::{run, AppCtx, WindowApp, WindowConfig};
}
+285
View File
@@ -0,0 +1,285 @@
//! Axis-aligned bounding box ([`Aabb`]).
//!
//! Stored as `min`/`max` corners. An AABB with any `min` component greater than
//! the corresponding `max` is considered *empty* (contains no points), which is
//! the natural identity for union operations.
use crate::math::Ray;
use glam::Vec3;
use serde::{Deserialize, Serialize};
/// An axis-aligned bounding box defined by its minimum and maximum corners.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Aabb {
/// Minimum corner (smallest x, y, z).
pub min: Vec3,
/// Maximum corner (largest x, y, z).
pub max: Vec3,
}
impl Aabb {
/// An empty box: `min` is `+inf`, `max` is `-inf`. Unioning any point with
/// this yields a box tightly bounding that point.
pub const EMPTY: Self = Self {
min: Vec3::splat(f32::INFINITY),
max: Vec3::splat(f32::NEG_INFINITY),
};
/// Creates an AABB from two corners, sorting components so `min <= max`.
#[inline]
pub fn new(a: Vec3, b: Vec3) -> Self {
Self {
min: a.min(b),
max: a.max(b),
}
}
/// Creates an AABB from a center point and half-extents.
#[inline]
pub fn from_center_half_extents(center: Vec3, half_extents: Vec3) -> Self {
Self {
min: center - half_extents,
max: center + half_extents,
}
}
/// Builds the tightest AABB containing all `points`. Returns [`Aabb::EMPTY`]
/// if the iterator is empty.
pub fn from_points(points: impl IntoIterator<Item = Vec3>) -> Self {
let mut bb = Self::EMPTY;
for p in points {
bb.expand_to_include(p);
}
bb
}
/// Returns `true` if this box contains no points (any axis inverted).
#[inline]
pub fn is_empty(&self) -> bool {
self.min.x > self.max.x || self.min.y > self.max.y || self.min.z > self.max.z
}
/// The center point of the box. Meaningless for an empty box.
#[inline]
pub fn center(&self) -> Vec3 {
(self.min + self.max) * 0.5
}
/// The full size (max - min) along each axis.
#[inline]
pub fn size(&self) -> Vec3 {
(self.max - self.min).max(Vec3::ZERO)
}
/// Half of [`Aabb::size`].
#[inline]
pub fn half_extents(&self) -> Vec3 {
self.size() * 0.5
}
/// The surface area of the box (used by spatial acceleration heuristics).
#[inline]
pub fn surface_area(&self) -> f32 {
let s = self.size();
2.0 * (s.x * s.y + s.y * s.z + s.z * s.x)
}
/// The volume of the box.
#[inline]
pub fn volume(&self) -> f32 {
let s = self.size();
s.x * s.y * s.z
}
/// Grows the box (in place) to include `point`.
#[inline]
pub fn expand_to_include(&mut self, point: Vec3) {
self.min = self.min.min(point);
self.max = self.max.max(point);
}
/// Returns the union of this box and `other` (smallest box containing both).
#[inline]
pub fn union(&self, other: &Aabb) -> Aabb {
Aabb {
min: self.min.min(other.min),
max: self.max.max(other.max),
}
}
/// Returns the intersection of two boxes, or [`Aabb::EMPTY`] if disjoint.
#[inline]
pub fn intersection(&self, other: &Aabb) -> Aabb {
let min = self.min.max(other.min);
let max = self.max.min(other.max);
if min.x > max.x || min.y > max.y || min.z > max.z {
Aabb::EMPTY
} else {
Aabb { min, max }
}
}
/// Returns `true` if `point` is inside or on the boundary of the box.
#[inline]
pub fn contains_point(&self, point: Vec3) -> bool {
point.cmpge(self.min).all() && point.cmple(self.max).all()
}
/// Returns `true` if the two boxes overlap (touching counts as overlap).
#[inline]
pub fn intersects(&self, other: &Aabb) -> bool {
self.min.cmple(other.max).all() && self.max.cmpge(other.min).all()
}
/// Returns the point on or inside the box closest to `point`.
#[inline]
pub fn closest_point(&self, point: Vec3) -> Vec3 {
point.clamp(self.min, self.max)
}
/// The eight corner vertices of the box.
pub fn corners(&self) -> [Vec3; 8] {
let (lo, hi) = (self.min, self.max);
[
Vec3::new(lo.x, lo.y, lo.z),
Vec3::new(hi.x, lo.y, lo.z),
Vec3::new(lo.x, hi.y, lo.z),
Vec3::new(hi.x, hi.y, lo.z),
Vec3::new(lo.x, lo.y, hi.z),
Vec3::new(hi.x, lo.y, hi.z),
Vec3::new(lo.x, hi.y, hi.z),
Vec3::new(hi.x, hi.y, hi.z),
]
}
/// Slab-method ray/box intersection. Returns the entry distance `t` along
/// the ray if it hits (including when the origin is inside, where `t` is the
/// clamped near distance), otherwise `None`.
pub fn ray_intersection(&self, ray: &Ray) -> Option<f32> {
let inv_dir = Vec3::ONE / ray.direction;
let t0 = (self.min - ray.origin) * inv_dir;
let t1 = (self.max - ray.origin) * inv_dir;
let t_near = t0.min(t1);
let t_far = t0.max(t1);
let t_enter = t_near.max_element();
let t_exit = t_far.min_element();
if t_enter <= t_exit && t_exit >= 0.0 {
Some(t_enter.max(0.0))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_box_contains_nothing() {
assert!(Aabb::EMPTY.is_empty());
assert!(!Aabb::EMPTY.contains_point(Vec3::ZERO));
}
#[test]
fn new_sorts_corners() {
let bb = Aabb::new(Vec3::new(1.0, 5.0, -2.0), Vec3::new(-1.0, 0.0, 3.0));
assert_eq!(bb.min, Vec3::new(-1.0, 0.0, -2.0));
assert_eq!(bb.max, Vec3::new(1.0, 5.0, 3.0));
}
#[test]
fn center_size_extents() {
let bb = Aabb::from_center_half_extents(Vec3::new(1.0, 2.0, 3.0), Vec3::splat(2.0));
assert_eq!(bb.center(), Vec3::new(1.0, 2.0, 3.0));
assert_eq!(bb.size(), Vec3::splat(4.0));
assert_eq!(bb.half_extents(), Vec3::splat(2.0));
}
#[test]
fn from_points_bounds_all() {
let bb = Aabb::from_points([
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(2.0, -1.0, 4.0),
Vec3::new(-3.0, 5.0, 1.0),
]);
assert_eq!(bb.min, Vec3::new(-3.0, -1.0, 0.0));
assert_eq!(bb.max, Vec3::new(2.0, 5.0, 4.0));
}
#[test]
fn from_points_empty_is_empty() {
assert!(Aabb::from_points([]).is_empty());
}
#[test]
fn contains_and_closest() {
let bb = Aabb::new(Vec3::ZERO, Vec3::splat(2.0));
assert!(bb.contains_point(Vec3::ONE));
assert!(bb.contains_point(Vec3::ZERO)); // boundary
assert!(!bb.contains_point(Vec3::new(3.0, 1.0, 1.0)));
assert_eq!(
bb.closest_point(Vec3::new(5.0, -1.0, 1.0)),
Vec3::new(2.0, 0.0, 1.0)
);
assert_eq!(bb.closest_point(Vec3::ONE), Vec3::ONE);
}
#[test]
fn union_and_intersection() {
let a = Aabb::new(Vec3::ZERO, Vec3::splat(2.0));
let b = Aabb::new(Vec3::ONE, Vec3::splat(3.0));
assert_eq!(a.union(&b), Aabb::new(Vec3::ZERO, Vec3::splat(3.0)));
assert_eq!(a.intersection(&b), Aabb::new(Vec3::ONE, Vec3::splat(2.0)));
let c = Aabb::new(Vec3::splat(5.0), Vec3::splat(6.0));
assert!(a.intersection(&c).is_empty());
assert!(!a.intersects(&c));
assert!(a.intersects(&b));
}
#[test]
fn surface_area_and_volume() {
let bb = Aabb::new(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0));
assert_eq!(bb.volume(), 6.0);
assert_eq!(bb.surface_area(), 2.0 * (2.0 + 6.0 + 3.0));
}
#[test]
fn corners_count_and_span() {
let bb = Aabb::new(Vec3::ZERO, Vec3::ONE);
let corners = bb.corners();
assert_eq!(corners.len(), 8);
assert!(corners.contains(&Vec3::ZERO));
assert!(corners.contains(&Vec3::ONE));
}
#[test]
fn ray_hits_from_outside() {
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::X);
let t = bb.ray_intersection(&ray).expect("should hit");
assert!((t - 4.0).abs() <= 1e-4);
}
#[test]
fn ray_from_inside_returns_zero() {
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
let ray = Ray::new(Vec3::ZERO, Vec3::X);
assert_eq!(bb.ray_intersection(&ray), Some(0.0));
}
#[test]
fn ray_misses() {
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
let ray = Ray::new(Vec3::new(-5.0, 5.0, 0.0), Vec3::X);
assert_eq!(bb.ray_intersection(&ray), None);
}
#[test]
fn ray_pointing_away_misses() {
let bb = Aabb::new(Vec3::splat(-1.0), Vec3::splat(1.0));
let ray = Ray::new(Vec3::new(-5.0, 0.0, 0.0), Vec3::NEG_X);
assert_eq!(bb.ray_intersection(&ray), None);
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Linear RGBA [`Color`].
//!
//! Colors are stored as `f32` components in **linear** space (the space shaders
//! and lighting math expect). Helpers convert to/from 8-bit sRGB for I/O.
use glam::{Vec3, Vec4};
use serde::{Deserialize, Serialize};
/// An RGBA color with linear `f32` components, nominally in `[0, 1]` but not
/// clamped (values above 1.0 represent HDR / emissive intensity).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Color {
/// Red channel (linear).
pub r: f32,
/// Green channel (linear).
pub g: f32,
/// Blue channel (linear).
pub b: f32,
/// Alpha (opacity); `1.0` is fully opaque.
pub a: f32,
}
impl Color {
/// Opaque black.
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
/// Opaque white.
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
/// Opaque red.
pub const RED: Self = Self::rgb(1.0, 0.0, 0.0);
/// Opaque green.
pub const GREEN: Self = Self::rgb(0.0, 1.0, 0.0);
/// Opaque blue.
pub const BLUE: Self = Self::rgb(0.0, 0.0, 1.0);
/// Fully transparent (all channels zero).
pub const TRANSPARENT: Self = Self::rgba(0.0, 0.0, 0.0, 0.0);
/// Creates a color from linear RGBA components.
#[inline]
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
/// Creates an opaque color from linear RGB components.
#[inline]
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
Self { r, g, b, a: 1.0 }
}
/// Creates a linear color from 8-bit **sRGB** components (the usual format
/// of color pickers and image files), with full opacity.
#[inline]
pub fn from_srgb_u8(r: u8, g: u8, b: u8) -> Self {
Self::rgb(
srgb_to_linear(r as f32 / 255.0),
srgb_to_linear(g as f32 / 255.0),
srgb_to_linear(b as f32 / 255.0),
)
}
/// Creates a linear color from a packed `0xRRGGBB` hex value.
#[inline]
pub fn from_hex(hex: u32) -> Self {
Self::from_srgb_u8(
((hex >> 16) & 0xFF) as u8,
((hex >> 8) & 0xFF) as u8,
(hex & 0xFF) as u8,
)
}
/// Converts to 8-bit sRGB `(r, g, b, a)`, clamping to `[0, 1]` first.
#[inline]
pub fn to_srgb_u8(&self) -> [u8; 4] {
[
(linear_to_srgb(self.r.clamp(0.0, 1.0)) * 255.0).round() as u8,
(linear_to_srgb(self.g.clamp(0.0, 1.0)) * 255.0).round() as u8,
(linear_to_srgb(self.b.clamp(0.0, 1.0)) * 255.0).round() as u8,
(self.a.clamp(0.0, 1.0) * 255.0).round() as u8,
]
}
/// Returns the color as a `Vec4` (`[r, g, b, a]`).
#[inline]
pub fn to_vec4(&self) -> Vec4 {
Vec4::new(self.r, self.g, self.b, self.a)
}
/// Returns the RGB channels as a `Vec3`.
#[inline]
pub fn to_vec3(&self) -> Vec3 {
Vec3::new(self.r, self.g, self.b)
}
/// Returns a copy with the alpha replaced.
#[inline]
pub fn with_alpha(&self, a: f32) -> Self {
Self { a, ..*self }
}
/// Linearly interpolates between two colors. `t` is clamped to `[0, 1]`.
#[inline]
pub fn lerp(&self, other: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
Color {
r: self.r + (other.r - self.r) * t,
g: self.g + (other.g - self.g) * t,
b: self.b + (other.b - self.b) * t,
a: self.a + (other.a - self.a) * t,
}
}
}
/// Converts a single sRGB channel value in `[0, 1]` to linear space.
#[inline]
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
/// Converts a single linear channel value in `[0, 1]` to sRGB space.
#[inline]
fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f32 = 1e-4;
#[test]
fn constants() {
assert_eq!(Color::WHITE, Color::rgb(1.0, 1.0, 1.0));
assert_eq!(Color::TRANSPARENT.a, 0.0);
}
#[test]
fn srgb_round_trip() {
let original = [10u8, 128, 240];
let c = Color::from_srgb_u8(original[0], original[1], original[2]);
let back = c.to_srgb_u8();
assert_eq!([back[0], back[1], back[2]], original);
assert_eq!(back[3], 255);
}
#[test]
fn srgb_endpoints_are_exact() {
assert!(srgb_to_linear(0.0).abs() <= EPS);
assert!((srgb_to_linear(1.0) - 1.0).abs() <= EPS);
assert!((linear_to_srgb(1.0) - 1.0).abs() <= EPS);
}
#[test]
fn hex_parsing() {
let c = Color::from_hex(0xFF0000);
assert_eq!(c.to_srgb_u8()[0], 255);
assert_eq!(c.to_srgb_u8()[1], 0);
assert_eq!(c.to_srgb_u8()[2], 0);
}
#[test]
fn lerp_endpoints_and_midpoint() {
let a = Color::rgba(0.0, 0.0, 0.0, 0.0);
let b = Color::rgba(1.0, 1.0, 1.0, 1.0);
assert_eq!(a.lerp(b, 0.0), a);
assert_eq!(a.lerp(b, 1.0), b);
assert_eq!(a.lerp(b, 0.5), Color::rgba(0.5, 0.5, 0.5, 0.5));
// Clamps out-of-range t.
assert_eq!(a.lerp(b, 2.0), b);
}
#[test]
fn vec_conversions_and_alpha() {
let c = Color::rgba(0.1, 0.2, 0.3, 0.4);
assert_eq!(c.to_vec4(), Vec4::new(0.1, 0.2, 0.3, 0.4));
assert_eq!(c.to_vec3(), Vec3::new(0.1, 0.2, 0.3));
assert_eq!(c.with_alpha(1.0).a, 1.0);
}
#[test]
fn hdr_values_not_clamped_in_storage() {
let c = Color::rgb(4.0, 0.0, 0.0);
assert_eq!(c.r, 4.0);
// But output is clamped.
assert_eq!(c.to_srgb_u8()[0], 255);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! A view [`Frustum`]: six planes used for visibility culling.
use crate::math::{Aabb, Plane};
use glam::{Mat4, Vec3, Vec4, Vec4Swizzles};
use serde::{Deserialize, Serialize};
/// A frustum represented by six bounding planes, each with its normal pointing
/// *inward*. A point is inside the frustum when it lies in the positive
/// half-space of every plane.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Frustum {
/// Planes ordered: left, right, bottom, top, near, far.
pub planes: [Plane; 6],
}
impl Frustum {
/// Extracts the six frustum planes from a combined view-projection matrix
/// using the GribbHartmann method. Works for both perspective and
/// orthographic projections.
pub fn from_view_projection(view_projection: Mat4) -> Self {
// Rows of the matrix (glam is column-major, so build rows explicitly).
let m = view_projection;
let row0 = Vec4::new(m.x_axis.x, m.y_axis.x, m.z_axis.x, m.w_axis.x);
let row1 = Vec4::new(m.x_axis.y, m.y_axis.y, m.z_axis.y, m.w_axis.y);
let row2 = Vec4::new(m.x_axis.z, m.y_axis.z, m.z_axis.z, m.w_axis.z);
let row3 = Vec4::new(m.x_axis.w, m.y_axis.w, m.z_axis.w, m.w_axis.w);
let plane_from = |v: Vec4| Plane::new(v.xyz(), v.w);
let planes = [
plane_from(row3 + row0), // left
plane_from(row3 - row0), // right
plane_from(row3 + row1), // bottom
plane_from(row3 - row1), // top
plane_from(row3 + row2), // near
plane_from(row3 - row2), // far
];
Self { planes }
}
/// Returns `true` if `point` is inside (or on the boundary of) the frustum.
pub fn contains_point(&self, point: Vec3) -> bool {
self.planes
.iter()
.all(|plane| plane.signed_distance(point) >= 0.0)
}
/// Returns `true` if any part of `aabb` is inside the frustum.
///
/// This is a conservative test: it may very rarely report a box as visible
/// when it is just outside a corner, but never culls a visible box. That is
/// the correct trade-off for rendering.
pub fn intersects_aabb(&self, aabb: &Aabb) -> bool {
for plane in &self.planes {
// The "positive vertex": the AABB corner farthest along the normal.
let p = Vec3::new(
if plane.normal.x >= 0.0 {
aabb.max.x
} else {
aabb.min.x
},
if plane.normal.y >= 0.0 {
aabb.max.y
} else {
aabb.min.y
},
if plane.normal.z >= 0.0 {
aabb.max.z
} else {
aabb.min.z
},
);
// If the farthest corner is behind a plane, the box is fully outside.
if plane.signed_distance(p) < 0.0 {
return false;
}
}
true
}
/// Returns `true` if the sphere at `center` with `radius` is at least
/// partially inside the frustum.
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
self.planes
.iter()
.all(|plane| plane.signed_distance(center) >= -radius)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn perspective_vp() -> Mat4 {
let proj = Mat4::perspective_rh(60_f32.to_radians(), 1.0, 1.0, 100.0);
let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::NEG_Z, Vec3::Y);
proj * view
}
#[test]
fn point_in_front_is_inside() {
let f = Frustum::from_view_projection(perspective_vp());
assert!(f.contains_point(Vec3::new(0.0, 0.0, -10.0)));
}
#[test]
fn point_behind_camera_is_outside() {
let f = Frustum::from_view_projection(perspective_vp());
assert!(!f.contains_point(Vec3::new(0.0, 0.0, 10.0)));
}
#[test]
fn point_beyond_far_is_outside() {
let f = Frustum::from_view_projection(perspective_vp());
assert!(!f.contains_point(Vec3::new(0.0, 0.0, -500.0)));
}
#[test]
fn point_way_off_to_side_is_outside() {
let f = Frustum::from_view_projection(perspective_vp());
assert!(!f.contains_point(Vec3::new(500.0, 0.0, -10.0)));
}
#[test]
fn aabb_in_view_intersects() {
let f = Frustum::from_view_projection(perspective_vp());
let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, -10.0), Vec3::splat(1.0));
assert!(f.intersects_aabb(&bb));
}
#[test]
fn aabb_behind_camera_is_culled() {
let f = Frustum::from_view_projection(perspective_vp());
let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 50.0), Vec3::splat(1.0));
assert!(!f.intersects_aabb(&bb));
}
#[test]
fn sphere_culling() {
let f = Frustum::from_view_projection(perspective_vp());
assert!(f.intersects_sphere(Vec3::new(0.0, 0.0, -10.0), 1.0));
// Just behind the camera but large enough to poke into the near plane.
assert!(!f.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 1.0));
}
#[test]
fn orthographic_frustum_works() {
let proj = Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, 1.0, 100.0);
let view = Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y);
let f = Frustum::from_view_projection(proj * view);
assert!(f.contains_point(Vec3::new(5.0, 5.0, -10.0)));
assert!(!f.contains_point(Vec3::new(50.0, 0.0, -10.0)));
}
}
+39
View File
@@ -0,0 +1,39 @@
//! Math and core geometric primitives.
//!
//! This module is the foundation every other Oxide system depends on. It builds
//! on [`glam`] for vectors, quaternions, and matrices, and adds the engine's own
//! higher-level types:
//!
//! - [`Transform`] — decomposed translation/rotation/scale, the unit of placement
//! - [`Aabb`] — axis-aligned bounding box for bounds and culling
//! - [`Ray`] — origin + direction, for picking and queries
//! - [`Plane`] — infinite plane in Hessian normal form
//! - [`Frustum`] — six-plane view volume for visibility culling
//! - [`Color`] — linear RGBA color with sRGB conversion
//! - [`Rect`] — 2D rectangle for UI and viewports
//! - [`Range3`] — 3D value range for clamping and remapping
//!
//! `glam`'s own types are re-exported so downstream crates have a single import
//! site for all math.
mod aabb;
mod color;
mod frustum;
mod plane;
mod range3;
mod ray;
mod rect;
mod transform;
pub use aabb::Aabb;
pub use color::Color;
pub use frustum::Frustum;
pub use plane::Plane;
pub use range3::Range3;
pub use ray::Ray;
pub use rect::Rect;
pub use transform::Transform;
// Re-export the most commonly used `glam` types so consumers don't need a
// separate dependency on `glam` for everyday math.
pub use glam::{EulerRot, Mat3, Mat4, Quat, Vec2, Vec3, Vec4};
+154
View File
@@ -0,0 +1,154 @@
//! An infinite [`Plane`] in Hessian normal form.
use crate::math::Ray;
use glam::Vec3;
use serde::{Deserialize, Serialize};
/// An infinite plane defined by a unit `normal` and a signed distance `d` from
/// the origin, such that every point `p` on the plane satisfies
/// `normal · p + d = 0`.
///
/// The positive half-space is the side the normal points toward.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Plane {
/// Unit-length plane normal.
pub normal: Vec3,
/// Signed distance from the origin along `-normal`.
pub d: f32,
}
impl Plane {
/// Creates a plane from a normal and signed distance, normalizing the input
/// (and scaling `d` to match) so the result is in Hessian normal form.
#[inline]
pub fn new(normal: Vec3, d: f32) -> Self {
let len = normal.length();
if len > 0.0 {
Self {
normal: normal / len,
d: d / len,
}
} else {
Self { normal, d }
}
}
/// Creates a plane from a point on it and a normal direction.
#[inline]
pub fn from_point_normal(point: Vec3, normal: Vec3) -> Self {
let n = normal.normalize_or_zero();
Self {
normal: n,
d: -n.dot(point),
}
}
/// Creates a plane through three points. Winding `a → b → c` determines the
/// normal direction (right-hand rule).
#[inline]
pub fn from_points(a: Vec3, b: Vec3, c: Vec3) -> Self {
let normal = (b - a).cross(c - a);
Self::from_point_normal(a, normal)
}
/// The signed distance from `point` to the plane. Positive on the side the
/// normal points toward, negative behind it, zero on the plane.
#[inline]
pub fn signed_distance(&self, point: Vec3) -> f32 {
self.normal.dot(point) + self.d
}
/// Projects `point` orthogonally onto the plane.
#[inline]
pub fn project_point(&self, point: Vec3) -> Vec3 {
point - self.normal * self.signed_distance(point)
}
/// Returns the intersection distance `t` along `ray`, or `None` if the ray
/// is parallel to the plane (or points away from it).
pub fn ray_intersection(&self, ray: &Ray) -> Option<f32> {
let denom = self.normal.dot(ray.direction);
if denom.abs() <= f32::EPSILON {
return None; // Parallel.
}
let t = -(self.normal.dot(ray.origin) + self.d) / denom;
(t >= 0.0).then_some(t)
}
/// Returns a plane facing the opposite direction (same geometric plane).
#[inline]
pub fn flipped(&self) -> Plane {
Plane {
normal: -self.normal,
d: -self.d,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f32 = 1e-4;
#[test]
fn from_point_normal_passes_through_point() {
let p = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
assert!(p.signed_distance(Vec3::new(5.0, 2.0, -3.0)).abs() <= EPS);
assert!((p.signed_distance(Vec3::new(0.0, 5.0, 0.0)) - 3.0).abs() <= EPS);
assert!((p.signed_distance(Vec3::ZERO) + 2.0).abs() <= EPS);
}
#[test]
fn new_normalizes() {
let p = Plane::new(Vec3::new(0.0, 0.0, 4.0), 8.0);
assert!((p.normal - Vec3::Z).length() <= EPS);
assert!((p.d - 2.0).abs() <= EPS);
}
#[test]
fn from_points_winding() {
let p = Plane::from_points(Vec3::ZERO, Vec3::X, Vec3::Y);
// X cross Y = Z.
assert!((p.normal - Vec3::Z).length() <= EPS);
assert!(p.d.abs() <= EPS);
}
#[test]
fn project_lands_on_plane() {
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
let proj = p.project_point(Vec3::new(3.0, 7.0, -2.0));
assert!((proj - Vec3::new(3.0, 0.0, -2.0)).length() <= EPS);
assert!(p.signed_distance(proj).abs() <= EPS);
}
#[test]
fn ray_intersects_plane() {
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y);
let t = p.ray_intersection(&ray).expect("should hit");
assert!((t - 5.0).abs() <= EPS);
}
#[test]
fn ray_parallel_misses() {
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::X);
assert_eq!(p.ray_intersection(&ray), None);
}
#[test]
fn ray_pointing_away_misses() {
let p = Plane::from_point_normal(Vec3::ZERO, Vec3::Y);
let ray = Ray::new(Vec3::new(0.0, 5.0, 0.0), Vec3::Y);
assert_eq!(p.ray_intersection(&ray), None);
}
#[test]
fn flipped_reverses_sign() {
let p = Plane::from_point_normal(Vec3::new(0.0, 2.0, 0.0), Vec3::Y);
let f = p.flipped();
let pt = Vec3::new(0.0, 5.0, 0.0);
assert!((p.signed_distance(pt) + f.signed_distance(pt)).abs() <= EPS);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! A 3D value [`Range3`]: an inclusive `[min, max]` interval per axis.
//!
//! Unlike [`Aabb`](crate::math::Aabb), which models geometry, `Range3` models a
//! *value range* — clamping configuration values, remapping parameters, and
//! describing generation bounds. It provides interpolation and remapping that
//! an AABB intentionally does not.
use glam::Vec3;
use serde::{Deserialize, Serialize};
/// An inclusive per-axis range `[min, max]`.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Range3 {
/// Lower bound on each axis.
pub min: Vec3,
/// Upper bound on each axis.
pub max: Vec3,
}
impl Range3 {
/// The unit range `[0, 1]` on every axis.
pub const UNIT: Self = Self {
min: Vec3::ZERO,
max: Vec3::ONE,
};
/// Creates a range from two bounds, sorting so `min <= max` per axis.
#[inline]
pub fn new(a: Vec3, b: Vec3) -> Self {
Self {
min: a.min(b),
max: a.max(b),
}
}
/// Creates a range spanning `[-extent, +extent]` on each axis.
#[inline]
pub fn symmetric(extent: Vec3) -> Self {
Self {
min: -extent,
max: extent,
}
}
/// The width of the range on each axis (`max - min`).
#[inline]
pub fn span(&self) -> Vec3 {
self.max - self.min
}
/// The midpoint of the range.
#[inline]
pub fn center(&self) -> Vec3 {
(self.min + self.max) * 0.5
}
/// Clamps `value` into the range per axis.
#[inline]
pub fn clamp(&self, value: Vec3) -> Vec3 {
value.clamp(self.min, self.max)
}
/// Returns `true` if `value` lies within the range (inclusive).
#[inline]
pub fn contains(&self, value: Vec3) -> bool {
value.cmpge(self.min).all() && value.cmple(self.max).all()
}
/// Linearly interpolates from `min` to `max` by `t` per axis. `t` is **not**
/// clamped, so values outside `[0, 1]` extrapolate.
#[inline]
pub fn lerp(&self, t: Vec3) -> Vec3 {
self.min + self.span() * t
}
/// The inverse of [`Range3::lerp`]: returns where `value` sits in `[0, 1]`
/// within the range, per axis. Axes with zero span yield `0.0`.
#[inline]
pub fn inverse_lerp(&self, value: Vec3) -> Vec3 {
let span = self.span();
let raw = (value - self.min) / span;
// Guard against division by zero on degenerate axes.
Vec3::select(span.cmpeq(Vec3::ZERO), Vec3::ZERO, raw)
}
/// Remaps `value` from this range into `target`, preserving its relative
/// position per axis.
#[inline]
pub fn remap(&self, value: Vec3, target: &Range3) -> Vec3 {
target.lerp(self.inverse_lerp(value))
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f32 = 1e-4;
fn approx(a: Vec3, b: Vec3) -> bool {
(a - b).length() <= EPS
}
#[test]
fn new_sorts_bounds() {
let r = Range3::new(Vec3::new(5.0, 0.0, -2.0), Vec3::new(1.0, 3.0, 4.0));
assert_eq!(r.min, Vec3::new(1.0, 0.0, -2.0));
assert_eq!(r.max, Vec3::new(5.0, 3.0, 4.0));
}
#[test]
fn symmetric_and_span_center() {
let r = Range3::symmetric(Vec3::splat(2.0));
assert_eq!(r.min, Vec3::splat(-2.0));
assert_eq!(r.span(), Vec3::splat(4.0));
assert_eq!(r.center(), Vec3::ZERO);
}
#[test]
fn clamp_and_contains() {
let r = Range3::new(Vec3::ZERO, Vec3::splat(10.0));
assert_eq!(
r.clamp(Vec3::new(-5.0, 5.0, 20.0)),
Vec3::new(0.0, 5.0, 10.0)
);
assert!(r.contains(Vec3::splat(5.0)));
assert!(!r.contains(Vec3::new(11.0, 5.0, 5.0)));
}
#[test]
fn lerp_and_inverse_round_trip() {
let r = Range3::new(Vec3::new(2.0, 4.0, 6.0), Vec3::new(4.0, 8.0, 12.0));
let t = Vec3::new(0.5, 0.25, 0.75);
let v = r.lerp(t);
assert!(approx(v, Vec3::new(3.0, 5.0, 10.5)));
assert!(approx(r.inverse_lerp(v), t));
}
#[test]
fn lerp_extrapolates() {
let r = Range3::UNIT;
assert!(approx(r.lerp(Vec3::splat(2.0)), Vec3::splat(2.0)));
assert!(approx(r.lerp(Vec3::splat(-1.0)), Vec3::splat(-1.0)));
}
#[test]
fn inverse_lerp_degenerate_axis_is_zero() {
let r = Range3::new(Vec3::new(5.0, 0.0, 0.0), Vec3::new(5.0, 10.0, 10.0));
// x axis has zero span → 0.0 rather than NaN/inf.
let result = r.inverse_lerp(Vec3::new(5.0, 5.0, 5.0));
assert!(result.x.is_finite());
assert_eq!(result.x, 0.0);
assert!((result.y - 0.5).abs() <= EPS);
}
#[test]
fn remap_between_ranges() {
let from = Range3::new(Vec3::ZERO, Vec3::splat(100.0));
let to = Range3::new(Vec3::ZERO, Vec3::ONE);
assert!(approx(from.remap(Vec3::splat(50.0), &to), Vec3::splat(0.5)));
}
}
+110
View File
@@ -0,0 +1,110 @@
//! A half-line [`Ray`] with an origin and a normalized direction.
use glam::Vec3;
use serde::{Deserialize, Serialize};
/// A ray: a point plus a direction, extending to infinity in one direction.
///
/// The direction is normalized on construction so that the parameter `t` in
/// [`Ray::at`] is a true distance.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Ray {
/// The starting point of the ray.
pub origin: Vec3,
/// The (normalized) direction of travel.
pub direction: Vec3,
}
impl Ray {
/// Creates a ray, normalizing `direction`.
///
/// If `direction` is zero-length it is left as-is (degenerate ray); callers
/// that care should validate with [`Ray::is_valid`].
#[inline]
pub fn new(origin: Vec3, direction: Vec3) -> Self {
Self {
origin,
direction: direction.normalize_or_zero(),
}
}
/// Creates a ray from an origin toward a target point.
#[inline]
pub fn from_to(origin: Vec3, target: Vec3) -> Self {
Self::new(origin, target - origin)
}
/// Returns the point at distance `t` along the ray.
#[inline]
pub fn at(&self, t: f32) -> Vec3 {
self.origin + self.direction * t
}
/// Returns `true` if the direction is a valid (non-zero, finite) unit vector.
#[inline]
pub fn is_valid(&self) -> bool {
self.direction.is_finite() && (self.direction.length_squared() - 1.0).abs() <= 1e-4
}
/// Returns the point on the ray closest to `point`, clamped to `t >= 0`.
#[inline]
pub fn closest_point(&self, point: Vec3) -> Vec3 {
let t = (point - self.origin).dot(self.direction).max(0.0);
self.at(t)
}
/// Returns the shortest distance from `point` to the ray.
#[inline]
pub fn distance_to_point(&self, point: Vec3) -> f32 {
self.closest_point(point).distance(point)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f32 = 1e-4;
#[test]
fn new_normalizes_direction() {
let ray = Ray::new(Vec3::ZERO, Vec3::new(0.0, 5.0, 0.0));
assert!((ray.direction.length() - 1.0).abs() <= EPS);
assert!(ray.is_valid());
}
#[test]
fn zero_direction_is_invalid() {
let ray = Ray::new(Vec3::ZERO, Vec3::ZERO);
assert!(!ray.is_valid());
}
#[test]
fn at_returns_distance_point() {
let ray = Ray::new(Vec3::new(1.0, 0.0, 0.0), Vec3::X);
assert!((ray.at(4.0) - Vec3::new(5.0, 0.0, 0.0)).length() <= EPS);
}
#[test]
fn from_to_points_at_target() {
let ray = Ray::from_to(Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0));
assert!((ray.direction - Vec3::Z).length() <= EPS);
}
#[test]
fn closest_point_and_distance() {
let ray = Ray::new(Vec3::ZERO, Vec3::X);
// Point off to the side.
let p = Vec3::new(3.0, 4.0, 0.0);
assert!((ray.closest_point(p) - Vec3::new(3.0, 0.0, 0.0)).length() <= EPS);
assert!((ray.distance_to_point(p) - 4.0).abs() <= EPS);
}
#[test]
fn closest_point_clamps_behind_origin() {
let ray = Ray::new(Vec3::ZERO, Vec3::X);
let p = Vec3::new(-5.0, 2.0, 0.0);
// Behind the origin → clamps to the origin.
assert!((ray.closest_point(p) - Vec3::ZERO).length() <= EPS);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! A 2D axis-aligned [`Rect`]angle, used for UI, viewports, and texture regions.
use glam::Vec2;
use serde::{Deserialize, Serialize};
/// An axis-aligned rectangle defined by its `min` (top-left in a y-down UI
/// space, or bottom-left in y-up) and `max` corners.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Rect {
/// Minimum corner (smallest x and y).
pub min: Vec2,
/// Maximum corner (largest x and y).
pub max: Vec2,
}
impl Rect {
/// A zero-area rectangle at the origin.
pub const ZERO: Self = Self {
min: Vec2::ZERO,
max: Vec2::ZERO,
};
/// Creates a rectangle from two corners, sorting so `min <= max`.
#[inline]
pub fn new(a: Vec2, b: Vec2) -> Self {
Self {
min: a.min(b),
max: a.max(b),
}
}
/// Creates a rectangle from a `min` corner and a size.
#[inline]
pub fn from_min_size(min: Vec2, size: Vec2) -> Self {
Self {
min,
max: min + size,
}
}
/// Creates a rectangle from a center point and full size.
#[inline]
pub fn from_center_size(center: Vec2, size: Vec2) -> Self {
let half = size * 0.5;
Self {
min: center - half,
max: center + half,
}
}
/// The width and height as a vector.
#[inline]
pub fn size(&self) -> Vec2 {
(self.max - self.min).max(Vec2::ZERO)
}
/// The width (x extent).
#[inline]
pub fn width(&self) -> f32 {
self.size().x
}
/// The height (y extent).
#[inline]
pub fn height(&self) -> f32 {
self.size().y
}
/// The center point.
#[inline]
pub fn center(&self) -> Vec2 {
(self.min + self.max) * 0.5
}
/// The area (`width * height`).
#[inline]
pub fn area(&self) -> f32 {
let s = self.size();
s.x * s.y
}
/// Returns `true` if the rectangle has zero (or inverted) area.
#[inline]
pub fn is_empty(&self) -> bool {
self.min.x >= self.max.x || self.min.y >= self.max.y
}
/// Returns `true` if `point` is inside or on the boundary.
#[inline]
pub fn contains_point(&self, point: Vec2) -> bool {
point.cmpge(self.min).all() && point.cmple(self.max).all()
}
/// Returns `true` if the two rectangles overlap (touching counts).
#[inline]
pub fn intersects(&self, other: &Rect) -> bool {
self.min.cmple(other.max).all() && self.max.cmpge(other.min).all()
}
/// Returns the overlapping region, or [`Rect::ZERO`] if disjoint.
#[inline]
pub fn intersection(&self, other: &Rect) -> Rect {
let min = self.min.max(other.min);
let max = self.max.min(other.max);
if min.x > max.x || min.y > max.y {
Rect::ZERO
} else {
Rect { min, max }
}
}
/// Returns the smallest rectangle containing both.
#[inline]
pub fn union(&self, other: &Rect) -> Rect {
Rect {
min: self.min.min(other.min),
max: self.max.max(other.max),
}
}
/// Returns the point inside the rectangle closest to `point`.
#[inline]
pub fn closest_point(&self, point: Vec2) -> Vec2 {
point.clamp(self.min, self.max)
}
/// Returns a copy expanded outward by `amount` on every side (negative
/// shrinks).
#[inline]
pub fn expanded(&self, amount: f32) -> Rect {
Rect {
min: self.min - Vec2::splat(amount),
max: self.max + Vec2::splat(amount),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_sorts_corners() {
let r = Rect::new(Vec2::new(4.0, 1.0), Vec2::new(0.0, 5.0));
assert_eq!(r.min, Vec2::new(0.0, 1.0));
assert_eq!(r.max, Vec2::new(4.0, 5.0));
}
#[test]
fn min_size_and_center_size() {
let r = Rect::from_min_size(Vec2::new(1.0, 2.0), Vec2::new(4.0, 6.0));
assert_eq!(r.size(), Vec2::new(4.0, 6.0));
assert_eq!(r.center(), Vec2::new(3.0, 5.0));
let c = Rect::from_center_size(Vec2::ZERO, Vec2::new(2.0, 2.0));
assert_eq!(c.min, Vec2::new(-1.0, -1.0));
assert_eq!(c.max, Vec2::new(1.0, 1.0));
}
#[test]
fn dimensions_and_area() {
let r = Rect::from_min_size(Vec2::ZERO, Vec2::new(3.0, 4.0));
assert_eq!(r.width(), 3.0);
assert_eq!(r.height(), 4.0);
assert_eq!(r.area(), 12.0);
}
#[test]
fn empty_detection() {
assert!(Rect::ZERO.is_empty());
assert!(Rect::new(Vec2::ZERO, Vec2::new(0.0, 5.0)).is_empty());
assert!(!Rect::from_min_size(Vec2::ZERO, Vec2::ONE).is_empty());
}
#[test]
fn contains_and_closest() {
let r = Rect::from_min_size(Vec2::ZERO, Vec2::splat(2.0));
assert!(r.contains_point(Vec2::ONE));
assert!(!r.contains_point(Vec2::new(3.0, 1.0)));
assert_eq!(r.closest_point(Vec2::new(5.0, -1.0)), Vec2::new(2.0, 0.0));
}
#[test]
fn intersection_and_union() {
let a = Rect::from_min_size(Vec2::ZERO, Vec2::splat(2.0));
let b = Rect::from_min_size(Vec2::ONE, Vec2::splat(2.0));
assert!(a.intersects(&b));
assert_eq!(a.intersection(&b), Rect::new(Vec2::ONE, Vec2::splat(2.0)));
assert_eq!(a.union(&b), Rect::new(Vec2::ZERO, Vec2::splat(3.0)));
let c = Rect::from_min_size(Vec2::splat(10.0), Vec2::ONE);
assert!(!a.intersects(&c));
assert_eq!(a.intersection(&c), Rect::ZERO);
}
#[test]
fn expanded_grows_and_shrinks() {
let r = Rect::from_min_size(Vec2::ZERO, Vec2::splat(4.0));
assert_eq!(
r.expanded(1.0),
Rect::new(Vec2::splat(-1.0), Vec2::splat(5.0))
);
assert_eq!(r.expanded(-1.0), Rect::new(Vec2::ONE, Vec2::splat(3.0)));
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Affine [`Transform`]: translation, rotation, and (non-uniform) scale.
//!
//! A `Transform` is the canonical way to place an object in space. It composes
//! as `parent * child`, matching the convention used by the scene graph in
//! later stages. Internally it is stored in decomposed (TRS) form so that
//! individual components stay editable without matrix round-trips.
use glam::{Affine3A, Mat4, Quat, Vec3};
use serde::{Deserialize, Serialize};
/// A 3D affine transform stored as translation, rotation, and scale.
///
/// The effective matrix is `T * R * S` (scale applied first, then rotation,
/// then translation), which is the standard convention for scene hierarchies.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
pub struct Transform {
/// World/local-space position.
pub translation: Vec3,
/// Orientation as a unit quaternion.
pub rotation: Quat,
/// Per-axis scale. May be non-uniform; zero or negative components are
/// permitted but make the transform non-invertible / mirror-inducing.
pub scale: Vec3,
}
impl Default for Transform {
/// The identity transform: no translation, no rotation, unit scale.
fn default() -> Self {
Self::IDENTITY
}
}
impl Transform {
/// The identity transform.
pub const IDENTITY: Self = Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
};
/// Creates a transform from a translation only (identity rotation, unit scale).
#[inline]
pub const fn from_translation(translation: Vec3) -> Self {
Self {
translation,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
}
}
/// Creates a transform from a rotation only.
#[inline]
pub const fn from_rotation(rotation: Quat) -> Self {
Self {
translation: Vec3::ZERO,
rotation,
scale: Vec3::ONE,
}
}
/// Creates a transform from a uniform scale.
#[inline]
pub const fn from_scale(scale: Vec3) -> Self {
Self {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale,
}
}
/// Creates a transform from all three components.
#[inline]
pub const fn from_trs(translation: Vec3, rotation: Quat, scale: Vec3) -> Self {
Self {
translation,
rotation,
scale,
}
}
/// Decomposes a 4x4 matrix back into a TRS transform.
///
/// Negative determinants (mirrored matrices) are handled by `glam`'s
/// decomposition, which folds the sign into the scale.
#[inline]
pub fn from_matrix(matrix: Mat4) -> Self {
let (scale, rotation, translation) = matrix.to_scale_rotation_translation();
Self {
translation,
rotation,
scale,
}
}
/// Returns the equivalent 4x4 homogeneous matrix.
#[inline]
pub fn to_matrix(&self) -> Mat4 {
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
/// Returns the equivalent [`Affine3A`], which is cheaper to compose than a
/// full `Mat4` and is what the renderer/scene graph use internally.
#[inline]
pub fn to_affine(&self) -> Affine3A {
Affine3A::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
/// Composes two transforms: `self * rhs` applies `rhs` first, then `self`.
///
/// This is exact for the translation and rotation channels. When either
/// operand carries non-uniform scale combined with rotation, the true
/// product is no longer a pure TRS transform; in that case the result is
/// re-decomposed from the composed matrix so the returned `Transform`
/// remains the closest TRS approximation. For uniform scale (the common
/// scene-graph case) the composition is exact.
#[inline]
pub fn mul_transform(&self, rhs: &Transform) -> Transform {
// Fast path: uniform scale composes exactly in TRS form.
if is_uniform(self.scale) {
let scale = self.scale * rhs.scale;
let rotation = self.rotation * rhs.rotation;
let translation = self.translation + self.rotation * (self.scale * rhs.translation);
Transform {
translation,
rotation,
scale,
}
} else {
Transform::from_matrix(self.to_matrix() * rhs.to_matrix())
}
}
/// Transforms a point (affected by translation, rotation, and scale).
#[inline]
pub fn transform_point(&self, point: Vec3) -> Vec3 {
self.translation + self.rotation * (self.scale * point)
}
/// Transforms a direction vector (rotation and scale only, no translation).
#[inline]
pub fn transform_vector(&self, vector: Vec3) -> Vec3 {
self.rotation * (self.scale * vector)
}
/// Returns the inverse transform, such that
/// `t.mul_transform(&t.inverse())` is approximately the identity.
///
/// # Panics
/// Does not panic, but if any scale component is zero the inverse scale
/// will contain infinities — the transform is not invertible in that case.
#[inline]
pub fn inverse(&self) -> Transform {
let inv_scale = Vec3::ONE / self.scale;
let inv_rotation = self.rotation.inverse();
let inv_translation = inv_rotation * (inv_scale * -self.translation);
Transform {
translation: inv_translation,
rotation: inv_rotation,
scale: inv_scale,
}
}
/// The local forward direction (`-Z`) rotated into this transform's space.
#[inline]
pub fn forward(&self) -> Vec3 {
self.rotation * Vec3::NEG_Z
}
/// The local up direction (`+Y`) rotated into this transform's space.
#[inline]
pub fn up(&self) -> Vec3 {
self.rotation * Vec3::Y
}
/// The local right direction (`+X`) rotated into this transform's space.
#[inline]
pub fn right(&self) -> Vec3 {
self.rotation * Vec3::X
}
/// Builds a transform positioned at `eye` looking toward `target`.
///
/// `up` is the reference up vector. Returns the identity rotation if `eye`
/// and `target` coincide.
pub fn looking_at(eye: Vec3, target: Vec3, up: Vec3) -> Transform {
let forward = target - eye;
let rotation = if forward.length_squared() <= f32::EPSILON {
Quat::IDENTITY
} else {
// glam's look_to is right-handed with -Z forward; invert the view
// rotation to get an object-space orientation.
Quat::from_mat4(&Mat4::look_to_rh(eye, forward.normalize(), up)).inverse()
};
Transform {
translation: eye,
rotation,
scale: Vec3::ONE,
}
}
/// Returns `true` if every component is finite (no NaN/inf).
#[inline]
pub fn is_finite(&self) -> bool {
self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite()
}
}
/// Returns `true` if all three components of `scale` are equal.
#[inline]
fn is_uniform(scale: Vec3) -> bool {
(scale.x - scale.y).abs() <= f32::EPSILON && (scale.y - scale.z).abs() <= f32::EPSILON
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::{FRAC_PI_2, PI};
const EPS: f32 = 1e-4;
fn approx_vec(a: Vec3, b: Vec3) -> bool {
(a - b).length() <= EPS
}
#[test]
fn identity_is_default() {
assert_eq!(Transform::default(), Transform::IDENTITY);
let p = Vec3::new(1.0, 2.0, 3.0);
assert_eq!(Transform::IDENTITY.transform_point(p), p);
}
#[test]
fn translation_moves_points() {
let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
assert!(approx_vec(
t.transform_point(Vec3::ZERO),
Vec3::new(1.0, 2.0, 3.0)
));
// Vectors ignore translation.
assert!(approx_vec(t.transform_vector(Vec3::X), Vec3::X));
}
#[test]
fn rotation_rotates_points() {
let t = Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2));
assert!(approx_vec(t.transform_point(Vec3::X), Vec3::Y));
}
#[test]
fn scale_scales_points() {
let t = Transform::from_scale(Vec3::new(2.0, 3.0, 4.0));
assert!(approx_vec(
t.transform_point(Vec3::ONE),
Vec3::new(2.0, 3.0, 4.0)
));
}
#[test]
fn matrix_round_trip() {
let t = Transform::from_trs(
Vec3::new(5.0, -2.0, 1.0),
Quat::from_euler(glam::EulerRot::XYZ, 0.3, -0.7, 1.1),
Vec3::new(2.0, 2.0, 2.0),
);
let back = Transform::from_matrix(t.to_matrix());
assert!(approx_vec(t.translation, back.translation));
assert!(approx_vec(t.scale, back.scale));
// Quaternions q and -q represent the same rotation.
let dot = t.rotation.dot(back.rotation).abs();
assert!((dot - 1.0).abs() <= EPS, "rotation mismatch: dot={dot}");
}
#[test]
fn inverse_cancels() {
let t = Transform::from_trs(
Vec3::new(3.0, 4.0, 5.0),
Quat::from_rotation_y(0.9),
Vec3::splat(2.0),
);
let id = t.mul_transform(&t.inverse());
assert!(approx_vec(id.translation, Vec3::ZERO));
assert!(approx_vec(id.scale, Vec3::ONE));
assert!(approx_vec(
id.transform_point(Vec3::new(7.0, 8.0, 9.0)),
Vec3::new(7.0, 8.0, 9.0)
));
}
#[test]
fn composition_matches_matrix() {
let a = Transform::from_trs(
Vec3::new(1.0, 0.0, -2.0),
Quat::from_rotation_x(0.4),
Vec3::splat(1.5),
);
let b = Transform::from_trs(
Vec3::new(-3.0, 2.0, 1.0),
Quat::from_rotation_z(-0.8),
Vec3::splat(0.5),
);
let composed = a.mul_transform(&b);
let p = Vec3::new(2.0, -1.0, 3.0);
let via_transform = composed.transform_point(p);
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
assert!(approx_vec(via_transform, via_matrix));
}
#[test]
fn nonuniform_composition_falls_back_to_matrix() {
let a = Transform::from_trs(
Vec3::new(0.0, 1.0, 0.0),
Quat::from_rotation_z(FRAC_PI_2),
Vec3::new(2.0, 1.0, 1.0),
);
let b = Transform::from_trs(
Vec3::new(1.0, 0.0, 0.0),
Quat::IDENTITY,
Vec3::new(1.0, 3.0, 1.0),
);
let composed = a.mul_transform(&b);
let p = Vec3::new(1.0, 2.0, -1.0);
let via_transform = composed.transform_point(p);
let via_matrix = (a.to_matrix() * b.to_matrix()).transform_point3(p);
// Re-decomposition keeps this close even with non-uniform scale.
assert!(
approx_vec(via_transform, via_matrix),
"{via_transform} vs {via_matrix}"
);
}
#[test]
fn zero_scale_is_non_invertible() {
let t = Transform::from_scale(Vec3::new(0.0, 1.0, 1.0));
let inv = t.inverse();
assert!(!inv.scale.x.is_finite());
assert!(t.is_finite()); // the forward transform itself is still finite
}
#[test]
fn gimbal_lock_path_stays_stable() {
// Pitch to +90° (a classic gimbal-lock orientation) and confirm the
// basis vectors remain orthonormal after round-tripping through a matrix.
let t =
Transform::from_rotation(Quat::from_euler(glam::EulerRot::YXZ, 0.0, FRAC_PI_2, 0.0));
let back = Transform::from_matrix(t.to_matrix());
assert!(approx_vec(back.forward(), t.forward()));
assert!(approx_vec(back.up(), t.up()));
// Orthonormality.
assert!(t.forward().dot(t.up()).abs() <= EPS);
assert!(t.right().dot(t.up()).abs() <= EPS);
}
#[test]
fn looking_at_faces_target() {
let t = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
// Forward should point toward the target (-Z world direction).
assert!(approx_vec(t.forward(), Vec3::NEG_Z));
}
#[test]
fn looking_at_degenerate_is_identity_rotation() {
let t = Transform::looking_at(Vec3::ONE, Vec3::ONE, Vec3::Y);
assert_eq!(t.rotation, Quat::IDENTITY);
}
#[test]
fn basis_vectors_for_half_turn() {
let t = Transform::from_rotation(Quat::from_rotation_y(PI));
assert!(approx_vec(t.forward(), Vec3::Z));
assert!(approx_vec(t.right(), Vec3::NEG_X));
}
}
+311
View File
@@ -0,0 +1,311 @@
//! Prefabs — named templates that spawn an entity already carrying a set of
//! components.
//!
//! The engine deliberately has **no parallel "object type" system**: an entity
//! *is* its set of components. A [`Prefab`] is therefore nothing more than a
//! named bundle of **(component name, value)** specs, applied on spawn through
//! the [`TypeRegistry`]. "Spawn a Cube" means "spawn an entity, then set its
//! `MeshRenderer` to a cube" — the same name-keyed path the editor and scripts
//! already use, so prefabs are pure data (serializable, dual-editable) rather
//! than code.
//!
//! This is what makes the editor's add-menu **data-driven**: the menu lists the
//! prefabs in a [`PrefabRegistry`] instead of hard-coding one button per type.
//!
//! ```
//! use oxide_engine::prelude::*;
//! use oxide_engine::prefab::{ComponentSpec, Prefab, PrefabRegistry};
//! use oxide_engine::reflect::TypeRegistry;
//!
//! // A registry that knows how to round-trip MeshRenderer by name.
//! let mut types = TypeRegistry::new();
//! types.register_reflected::<MeshRenderer>("MeshRenderer");
//!
//! // A "Cube" prefab: an entity carrying 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();
//! assert!(types.has(scene.world(), cube, "MeshRenderer").unwrap());
//! ```
use std::collections::BTreeMap;
use hecs::Entity;
use serde::{Deserialize, Serialize};
use crate::math::Transform;
use crate::reflect::TypeRegistry;
use crate::scene::Scene;
/// One component a prefab attaches: a registered type **name** plus its value
/// serialized as **RON** — the same representation [`TypeRegistry::set_ron`]
/// consumes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComponentSpec {
/// The component's registered name in the [`TypeRegistry`].
pub type_name: String,
/// The component value as RON.
pub ron: String,
}
impl ComponentSpec {
/// A spec from a name and an already-serialized RON string.
pub fn new(type_name: impl Into<String>, ron: impl Into<String>) -> Self {
Self {
type_name: type_name.into(),
ron: ron.into(),
}
}
/// A spec built by serializing a concrete component `value`. Returns `None`
/// if it cannot be serialized to RON.
pub fn of<T: Serialize>(type_name: impl Into<String>, value: &T) -> Option<Self> {
ron::to_string(value)
.ok()
.map(|ron| Self::new(type_name, ron))
}
}
/// A named spawn template: a node name plus the components to attach beyond the
/// node-baked ones.
///
/// Every spawned entity already carries `Node`, `Transform`, and `Layer`
/// (auto-attached by [`Scene::spawn`]); a prefab's [`components`](Self::components)
/// are layered on top. A spec named `"Transform"` overrides the identity
/// transform `spawn` starts with, so a prefab can place itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Prefab {
/// The name given to the spawned node (also the registry key).
pub name: String,
/// Components attached on spawn, applied in order.
pub components: Vec<ComponentSpec>,
}
impl Prefab {
/// An empty prefab (spawns a bare node with just the node-baked components).
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
components: Vec::new(),
}
}
/// Adds a component spec (builder style).
pub fn with(mut self, spec: ComponentSpec) -> Self {
self.components.push(spec);
self
}
}
/// A registry of prefabs keyed by name — the data-driven source for the
/// editor's "add an entity that already carries these components" menu.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrefabRegistry {
prefabs: BTreeMap<String, Prefab>,
}
impl PrefabRegistry {
/// An empty registry.
pub fn new() -> Self {
Self::default()
}
/// Registers `prefab` under its [`name`](Prefab::name). Re-registering the
/// same name replaces the entry.
pub fn register(&mut self, prefab: Prefab) {
self.prefabs.insert(prefab.name.clone(), prefab);
}
/// The prefab registered under `name`, if any.
pub fn get(&self, name: &str) -> Option<&Prefab> {
self.prefabs.get(name)
}
/// Whether a prefab is registered under `name`.
pub fn contains(&self, name: &str) -> bool {
self.prefabs.contains_key(name)
}
/// The registered prefab names, sorted — what an add-menu lists.
pub fn names(&self) -> impl Iterator<Item = &str> + '_ {
self.prefabs.keys().map(String::as_str)
}
/// The number of registered prefabs.
pub fn len(&self) -> usize {
self.prefabs.len()
}
/// Whether no prefabs are registered.
pub fn is_empty(&self) -> bool {
self.prefabs.is_empty()
}
/// Spawns the named prefab as a **root** entity, applying its component
/// specs through `registry`. Returns the new entity, or `None` if `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). Use [`unknown_specs`](Self::unknown_specs) to validate a prefab
/// against a registry up front.
pub fn spawn(&self, name: &str, scene: &mut Scene, registry: &TypeRegistry) -> Option<Entity> {
let prefab = self.prefabs.get(name)?;
let entity = scene.spawn(prefab.name.clone(), Transform::IDENTITY);
apply(prefab, entity, scene, registry);
Some(entity)
}
/// Like [`spawn`](Self::spawn) but parents the new entity under `parent`.
pub fn spawn_child(
&self,
name: &str,
parent: Entity,
scene: &mut Scene,
registry: &TypeRegistry,
) -> Option<Entity> {
let prefab = self.prefabs.get(name)?;
let entity = scene.spawn_child(parent, prefab.name.clone(), Transform::IDENTITY);
apply(prefab, entity, scene, registry);
Some(entity)
}
/// The type names a prefab references that `registry` doesn't know — empty
/// when the prefab will spawn fully. Handy for surfacing authoring typos.
pub fn unknown_specs(&self, name: &str, registry: &TypeRegistry) -> Vec<String> {
self.prefabs
.get(name)
.map(|p| {
p.components
.iter()
.filter(|s| !registry.is_registered(&s.type_name))
.map(|s| s.type_name.clone())
.collect()
})
.unwrap_or_default()
}
}
/// Applies a prefab's component specs onto an already-spawned `entity`.
fn apply(prefab: &Prefab, entity: Entity, scene: &mut Scene, registry: &TypeRegistry) {
for spec in &prefab.components {
// Best-effort: an unknown type or malformed RON simply doesn't apply,
// leaving the rest of the prefab intact.
let _ = registry.set_ron(scene.world_mut(), entity, &spec.type_name, &spec.ron);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::{MeshRenderer, PrimitiveShape};
use crate::scene::Node;
fn types() -> TypeRegistry {
let mut r = TypeRegistry::new();
r.register_reflected::<Transform>("Transform");
r.register_reflected::<MeshRenderer>("MeshRenderer");
r
}
#[test]
fn registry_lists_names_sorted_and_looks_up() {
let mut prefabs = PrefabRegistry::new();
prefabs.register(Prefab::new("Sphere"));
prefabs.register(Prefab::new("Cube"));
assert_eq!(prefabs.names().collect::<Vec<_>>(), vec!["Cube", "Sphere"]);
assert!(prefabs.contains("Cube"));
assert!(prefabs.get("Cube").is_some());
assert_eq!(prefabs.len(), 2);
}
#[test]
fn spawn_attaches_specced_components() {
let types = types();
let mut prefabs = PrefabRegistry::new();
let mesh = MeshRenderer {
shape: PrimitiveShape::Sphere,
..MeshRenderer::default()
};
prefabs
.register(Prefab::new("Ball").with(ComponentSpec::of("MeshRenderer", &mesh).unwrap()));
let mut scene = Scene::new();
let e = prefabs.spawn("Ball", &mut scene, &types).unwrap();
// Node name comes from the prefab; the spec'd component is attached.
assert_eq!(scene.world().get::<&Node>(e).unwrap().name, "Ball");
let got = scene.world().get::<&MeshRenderer>(e).unwrap();
assert_eq!(got.shape, PrimitiveShape::Sphere);
}
#[test]
fn spawn_child_parents_under_the_target() {
let types = types();
let mut prefabs = PrefabRegistry::new();
prefabs.register(Prefab::new("Child"));
let mut scene = Scene::new();
let parent = scene.spawn("parent", Transform::IDENTITY);
let child = prefabs
.spawn_child("Child", parent, &mut scene, &types)
.unwrap();
assert_eq!(scene.parent(child), Some(parent));
}
#[test]
fn transform_spec_overrides_the_identity_spawn() {
let types = types();
let mut prefabs = PrefabRegistry::new();
let placed = Transform::from_translation(crate::math::Vec3::new(1.0, 2.0, 3.0));
prefabs
.register(Prefab::new("Placed").with(ComponentSpec::of("Transform", &placed).unwrap()));
let mut scene = Scene::new();
let e = prefabs.spawn("Placed", &mut scene, &types).unwrap();
let t = scene.local_transform(e).unwrap();
assert!((t.translation - crate::math::Vec3::new(1.0, 2.0, 3.0)).length() < 1e-6);
}
#[test]
fn unknown_prefab_name_spawns_nothing() {
let types = types();
let prefabs = PrefabRegistry::new();
let mut scene = Scene::new();
assert!(prefabs.spawn("Nope", &mut scene, &types).is_none());
}
#[test]
fn unknown_specs_are_reported_and_skipped() {
let types = types(); // knows Transform + MeshRenderer
let mut prefabs = PrefabRegistry::new();
prefabs.register(
Prefab::new("Mixed")
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap())
.with(ComponentSpec::new("Ghost", "()")),
);
assert_eq!(prefabs.unknown_specs("Mixed", &types), vec!["Ghost"]);
// Spawn still succeeds; the known component applies, the ghost is skipped.
let mut scene = Scene::new();
let e = prefabs.spawn("Mixed", &mut scene, &types).unwrap();
assert!(types.has(scene.world(), e, "MeshRenderer").unwrap());
}
#[test]
fn prefab_round_trips_through_ron() {
let mut prefabs = PrefabRegistry::new();
prefabs.register(
Prefab::new("Cube")
.with(ComponentSpec::of("MeshRenderer", &MeshRenderer::default()).unwrap()),
);
let ron = ron::to_string(&prefabs).unwrap();
let back: PrefabRegistry = ron::from_str(&ron).unwrap();
assert_eq!(prefabs, back);
}
}
+399
View File
@@ -0,0 +1,399 @@
//! Projects: the on-disk unit a game is authored as.
//!
//! A **project** is a root directory containing a project file plus a defined
//! folder layout (scenes, assets, scripts). The project file (RON) records the
//! project name, the engine version it was made with, the set of enabled
//! [modules](crate::app::Module), and per-project settings. The format lives in
//! the engine — not the editor — because the exported runtime and the Stage-16
//! packer read it too; the editor adds the create/open/save UI on top.
//!
//! Per-project settings are stored as **opaque per-section RON blobs**
//! (`section name → RON`), so this module stays independent of the typed
//! settings framework: that framework serializes its typed sections to these
//! strings and back.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The project file's name within the project root.
pub const PROJECT_FILE_NAME: &str = "project.oxide";
/// The subdirectory holding scene files.
pub const SCENES_DIR: &str = "scenes";
/// The subdirectory holding asset files (meshes, textures, audio, …).
pub const ASSETS_DIR: &str = "assets";
/// The subdirectory holding game scripts.
pub const SCRIPTS_DIR: &str = "scripts";
/// Errors from project operations.
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
/// A project file already exists where a new project was to be created.
#[error("a project already exists at {0}")]
AlreadyExists(PathBuf),
/// No project file was found at the given location.
#[error("no project file found at {0}")]
NotFound(PathBuf),
/// Filesystem I/O failed.
#[error("project i/o error at {path}: {source}")]
Io {
/// The path involved.
path: PathBuf,
/// The underlying error.
source: std::io::Error,
},
/// The project file could not be parsed.
#[error("malformed project file at {path}: {message}")]
Parse {
/// The project file path.
path: PathBuf,
/// The parser message.
message: String,
},
/// The project file could not be serialized.
#[error("failed to serialize project: {0}")]
Serialize(String),
}
/// The serialized contents of a project file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectMeta {
/// Human-readable project name.
pub name: String,
/// The engine version this project was last saved with.
pub engine_version: String,
/// Names of the modules enabled for this project.
pub enabled_modules: Vec<String>,
/// Per-project settings as opaque RON blobs, keyed by section name. The
/// typed settings framework round-trips its sections through here.
pub settings: BTreeMap<String, String>,
}
impl ProjectMeta {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
engine_version: env!("CARGO_PKG_VERSION").to_string(),
enabled_modules: Vec::new(),
settings: BTreeMap::new(),
}
}
}
/// An open project: its root directory plus the loaded [`ProjectMeta`].
#[derive(Debug, Clone)]
pub struct Project {
root: PathBuf,
meta: ProjectMeta,
}
impl Project {
/// Creates a new project rooted at `root` (created if missing), scaffolding
/// the `scenes`/`assets`/`scripts` folders and writing the project file.
///
/// # Errors
/// [`AlreadyExists`](ProjectError::AlreadyExists) if a project file is
/// already present, or [`Io`](ProjectError::Io) on filesystem failure.
pub fn create(root: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, ProjectError> {
let root = root.as_ref().to_path_buf();
let file = root.join(PROJECT_FILE_NAME);
if file.exists() {
return Err(ProjectError::AlreadyExists(file));
}
for dir in [
&root,
&root.join(SCENES_DIR),
&root.join(ASSETS_DIR),
&root.join(SCRIPTS_DIR),
] {
std::fs::create_dir_all(dir).map_err(|source| ProjectError::Io {
path: dir.clone(),
source,
})?;
}
let project = Self {
root,
meta: ProjectMeta::new(name),
};
project.save()?;
Ok(project)
}
/// Opens an existing project. `path` may be the project root directory or
/// the project file itself.
///
/// # Errors
/// [`NotFound`](ProjectError::NotFound) if no project file is present, or
/// [`Parse`](ProjectError::Parse)/[`Io`](ProjectError::Io) on failure.
pub fn open(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
let path = path.as_ref();
let (root, file) = if path.is_dir() {
(path.to_path_buf(), path.join(PROJECT_FILE_NAME))
} else {
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
(root, path.to_path_buf())
};
if !file.exists() {
return Err(ProjectError::NotFound(file));
}
let text = std::fs::read_to_string(&file).map_err(|source| ProjectError::Io {
path: file.clone(),
source,
})?;
let meta: ProjectMeta = ron::from_str(&text).map_err(|err| ProjectError::Parse {
path: file.clone(),
message: err.to_string(),
})?;
Ok(Self { root, meta })
}
/// Writes the project file, stamping it with the current engine version.
pub fn save(&self) -> Result<(), ProjectError> {
let file = self.project_file_path();
let pretty = ron::ser::PrettyConfig::default();
let text = ron::ser::to_string_pretty(&self.meta, pretty)
.map_err(|err| ProjectError::Serialize(err.to_string()))?;
std::fs::write(&file, text).map_err(|source| ProjectError::Io { path: file, source })
}
// --- Layout ------------------------------------------------------------
/// The project root directory.
pub fn root(&self) -> &Path {
&self.root
}
/// The path of the project file.
pub fn project_file_path(&self) -> PathBuf {
self.root.join(PROJECT_FILE_NAME)
}
/// The scenes directory.
pub fn scenes_dir(&self) -> PathBuf {
self.root.join(SCENES_DIR)
}
/// The assets directory.
pub fn assets_dir(&self) -> PathBuf {
self.root.join(ASSETS_DIR)
}
/// The scripts directory.
pub fn scripts_dir(&self) -> PathBuf {
self.root.join(SCRIPTS_DIR)
}
// --- Metadata ----------------------------------------------------------
/// The project's metadata (name, modules, settings).
pub fn meta(&self) -> &ProjectMeta {
&self.meta
}
/// The project name.
pub fn name(&self) -> &str {
&self.meta.name
}
/// Renames the project (call [`save`](Self::save) to persist).
pub fn set_name(&mut self, name: impl Into<String>) {
self.meta.name = name.into();
}
/// Whether `module` is enabled for this project.
pub fn is_module_enabled(&self, module: &str) -> bool {
self.meta.enabled_modules.iter().any(|m| m == module)
}
/// Enables `module` (no-op if already enabled).
pub fn enable_module(&mut self, module: impl Into<String>) {
let module = module.into();
if !self.is_module_enabled(&module) {
self.meta.enabled_modules.push(module);
}
}
/// Disables `module`. Returns whether it was enabled.
pub fn disable_module(&mut self, module: &str) -> bool {
let before = self.meta.enabled_modules.len();
self.meta.enabled_modules.retain(|m| m != module);
self.meta.enabled_modules.len() != before
}
/// The raw RON blob stored for settings `section`, if any.
pub fn settings_section(&self, section: &str) -> Option<&str> {
self.meta.settings.get(section).map(String::as_str)
}
/// Stores a raw RON blob for settings `section`.
pub fn set_settings_section(&mut self, section: impl Into<String>, ron: impl Into<String>) {
self.meta.settings.insert(section.into(), ron.into());
}
}
/// A most-recently-used list of project roots, persisted globally (an editor
/// preference, not part of any single project).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecentProjects {
entries: Vec<PathBuf>,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
10
}
impl RecentProjects {
/// A list retaining at most `limit` entries.
pub fn new(limit: usize) -> Self {
Self {
entries: Vec::new(),
limit: limit.max(1),
}
}
/// Records `root` as the most recent project, de-duplicating and capping.
pub fn record(&mut self, root: impl AsRef<Path>) {
let root = root.as_ref().to_path_buf();
self.entries.retain(|p| p != &root);
self.entries.insert(0, root);
self.entries.truncate(self.limit.max(1));
}
/// The recorded roots, most-recent first.
pub fn entries(&self) -> &[PathBuf] {
&self.entries
}
/// Loads the list from a RON file, or returns an empty list if absent.
pub fn load(path: impl AsRef<Path>) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|text| ron::from_str(&text).ok())
.unwrap_or_default()
}
/// Saves the list to a RON file.
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| std::io::Error::other(e.to_string()))?;
std::fs::write(path, text)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(tag: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"oxide_project_test_{}_{}_{tag}",
std::process::id(),
// A counter to keep tests isolated within the process.
COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
));
path
}
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
#[test]
fn create_scaffolds_layout_and_file() {
let root = temp_root("create");
let project = Project::create(&root, "My Game").unwrap();
assert!(project.project_file_path().exists());
assert!(project.scenes_dir().is_dir());
assert!(project.assets_dir().is_dir());
assert!(project.scripts_dir().is_dir());
assert_eq!(project.name(), "My Game");
std::fs::remove_dir_all(root).ok();
}
#[test]
fn create_then_open_round_trips() {
let root = temp_root("roundtrip");
let mut project = Project::create(&root, "Game").unwrap();
project.enable_module("physics");
project.enable_module("audio");
project.set_settings_section("editor", "(theme:\"dark\")");
project.save().unwrap();
// Open by directory.
let opened = Project::open(&root).unwrap();
assert_eq!(opened.name(), "Game");
assert!(opened.is_module_enabled("physics") && opened.is_module_enabled("audio"));
assert_eq!(opened.settings_section("editor"), Some("(theme:\"dark\")"));
// Open by file path.
let by_file = Project::open(opened.project_file_path()).unwrap();
assert_eq!(by_file.meta(), opened.meta());
std::fs::remove_dir_all(root).ok();
}
#[test]
fn create_refuses_to_overwrite() {
let root = temp_root("nooverwrite");
Project::create(&root, "A").unwrap();
let err = Project::create(&root, "B").unwrap_err();
assert!(matches!(err, ProjectError::AlreadyExists(_)));
std::fs::remove_dir_all(root).ok();
}
#[test]
fn open_missing_is_not_found() {
let root = temp_root("missing");
let err = Project::open(&root).unwrap_err();
assert!(matches!(err, ProjectError::NotFound(_)));
}
#[test]
fn module_enable_disable() {
let root = temp_root("modules");
let mut project = Project::create(&root, "M").unwrap();
project.enable_module("terrain");
project.enable_module("terrain"); // idempotent
assert_eq!(project.meta().enabled_modules, vec!["terrain"]);
assert!(project.disable_module("terrain"));
assert!(!project.disable_module("terrain"));
assert!(!project.is_module_enabled("terrain"));
std::fs::remove_dir_all(root).ok();
}
#[test]
fn recent_projects_dedup_and_cap() {
let mut recent = RecentProjects::new(3);
recent.record("/a");
recent.record("/b");
recent.record("/a"); // moves /a to front, no dup
recent.record("/c");
recent.record("/d"); // evicts the oldest (/b)
let entries: Vec<_> = recent
.entries()
.iter()
.map(|p| p.to_str().unwrap())
.collect();
assert_eq!(entries, vec!["/d", "/c", "/a"]);
}
#[test]
fn recent_projects_persist() {
let root = temp_root("recent");
std::fs::create_dir_all(&root).unwrap();
let file = root.join("recent.ron");
let mut recent = RecentProjects::new(5);
recent.record("/x");
recent.record("/y");
recent.save(&file).unwrap();
let loaded = RecentProjects::load(&file);
assert_eq!(loaded.entries(), recent.entries());
std::fs::remove_dir_all(root).ok();
}
}
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
//! [`Camera`]: perspective projection plus view/projection matrix helpers.
//!
//! A camera holds only projection parameters; its *position* is a
//! [`Transform`] supplied at render time (so a camera can be an entity in the
//! scene). The view matrix is the inverse of that world transform.
use serde::{Deserialize, Serialize};
use crate::layer::{Layer, LayerMask};
use crate::math::{Mat4, Transform};
/// A perspective camera.
///
/// Stage 4 ships perspective projection only; orthographic and other
/// projections can be added later without changing the renderer interface.
///
/// A `Camera` is also a **reflected, addable component**: place one on an
/// entity and it becomes the scene's viewpoint, dual-editable from the editor
/// and scripts like any other component. (The runtime gathering of camera
/// entities into the render path is wired in a later stage; today the editor
/// drives its own viewport camera.)
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, crate::reflect::Reflect)]
pub struct Camera {
/// Vertical field of view, in radians.
pub fov_y: f32,
/// Near clip plane distance (> 0).
pub z_near: f32,
/// Far clip plane distance (> `z_near`).
pub z_far: f32,
/// The layers this camera renders. An entity is drawn only if its
/// [`Layer`](crate::layer::Layer) membership intersects this mask. Defaults
/// to [`LayerMask::ALL`] (sees everything) — e.g. a minimap or first-person
/// view-model camera narrows it. The host applies it when gathering objects.
pub visibility: LayerMask,
}
impl Default for Camera {
/// A 60° vertical FOV camera with a 0.11000 unit depth range that sees all
/// layers.
fn default() -> Self {
Self {
fov_y: 60_f32.to_radians(),
z_near: 0.1,
z_far: 1000.0,
visibility: LayerMask::ALL,
}
}
}
impl Camera {
/// Creates a perspective camera from a vertical FOV (radians) and clip range,
/// seeing all layers.
pub fn perspective(fov_y: f32, z_near: f32, z_far: f32) -> Self {
Self {
fov_y,
z_near,
z_far,
visibility: LayerMask::ALL,
}
}
/// Sets the layer-visibility mask (builder style).
pub fn with_visibility(mut self, visibility: LayerMask) -> Self {
self.visibility = visibility;
self
}
/// Whether this camera renders an entity with the given layer membership.
pub fn sees(&self, layer: Layer) -> bool {
layer.matches(self.visibility)
}
/// The projection matrix for a viewport of the given `aspect` (width /
/// height). Uses a reversed-Z-free, `0..1` NDC depth range (wgpu/Vulkan/
/// DX/Metal convention).
pub fn projection_matrix(&self, aspect: f32) -> Mat4 {
Mat4::perspective_rh(
self.fov_y,
aspect.max(f32::EPSILON),
self.z_near,
self.z_far,
)
}
/// The view matrix for a camera placed at `view_transform` — i.e. the
/// inverse of the camera's world transform.
pub fn view_matrix(view_transform: &Transform) -> Mat4 {
view_transform.to_matrix().inverse()
}
/// The combined view-projection matrix: `projection * view`.
pub fn view_projection(&self, aspect: f32, view_transform: &Transform) -> Mat4 {
self.projection_matrix(aspect) * Self::view_matrix(view_transform)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Vec3;
#[test]
fn visibility_filters_by_layer() {
// Default camera sees every layer.
let cam = Camera::default();
assert!(cam.sees(Layer::on(7)));
// A camera restricted to the "UI" layer (3) only sees layer-3 entities.
let ui_cam = Camera::default().with_visibility(LayerMask::layer(3));
assert!(ui_cam.sees(Layer::on(3)));
assert!(!ui_cam.sees(Layer::on(0)));
assert!(!ui_cam.sees(Layer::default())); // default layer 0
}
#[test]
fn projection_is_finite_and_depth_mapped() {
let cam = Camera::default();
let proj = cam.projection_matrix(16.0 / 9.0);
assert!(proj.is_finite());
// A point on the near plane maps to NDC z ~ 0, the far plane to ~ 1.
let near = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_near));
let far = proj.project_point3(Vec3::new(0.0, 0.0, -cam.z_far));
assert!(near.z.abs() < 1e-3, "near z = {}", near.z);
assert!((far.z - 1.0).abs() < 1e-3, "far z = {}", far.z);
}
#[test]
fn view_matrix_moves_world_into_camera_space() {
// Camera at +Z looking at the origin: the origin should sit straight
// ahead, down the camera's -Z axis.
let cam_tf = Transform::looking_at(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
let view = Camera::view_matrix(&cam_tf);
let origin_in_view = view.project_point3(Vec3::ZERO);
assert!((origin_in_view.x).abs() < 1e-5);
assert!((origin_in_view.y).abs() < 1e-5);
assert!(
(origin_in_view.z + 5.0).abs() < 1e-4,
"z = {}",
origin_in_view.z
);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Window surface rendering: swapchain configuration, resize, clear loop.
use std::sync::Arc;
use winit::window::Window;
use super::{clear_view, Gpu, RenderError};
use crate::math::Color;
use crate::window::RenderCtx;
/// Renders to a window surface.
///
/// Owns the [`Gpu`] plus the window's [`wgpu::Surface`] and its
/// configuration. Stage 2 scope: every frame is cleared to
/// [`clear_color`](Self::clear_color); draw passes come in later stages.
pub struct RenderContext {
gpu: Gpu,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
clear_color: Color,
}
impl RenderContext {
/// Acquires the GPU and configures a surface for `window`.
///
/// The window is held by `Arc` so the surface (which borrows it) can be
/// `'static`, as winit hands out windows from its event loop.
///
/// To run on any device, several render backends are tried in turn — the
/// default (env-selected Vulkan/Metal/DX12), then GL, then a software
/// adapter — and the first that produces a *configurable* surface wins.
/// This is what lets the engine survive drivers that report a GPU but
/// cannot present to the window's surface (e.g. old NVIDIA on Wayland under
/// Vulkan, where `surface.configure` would otherwise fail).
pub fn new(window: Arc<Window>) -> Result<Self, RenderError> {
// (label, backend override, force a software adapter)
let attempts: [(&str, Option<wgpu::Backends>, bool); 3] = [
("default", None, false),
("GL", Some(wgpu::Backends::GL), false),
("software", None, true),
];
let mut last_err: Option<RenderError> = None;
for (i, &(label, backends, force_fallback)) in attempts.iter().enumerate() {
match Self::try_backend(&window, backends, force_fallback) {
Ok(ctx) => {
if i > 0 {
log::warn!("render backend fell back to '{label}'");
}
return Ok(ctx);
}
Err(err) => {
log::warn!("render backend '{label}' unavailable: {err}");
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or(RenderError::NoWorkingBackend))
}
/// Attempts one backend: build an instance (optionally forcing `backends`),
/// create the surface, acquire an adapter/device (optionally a software
/// one), and configure the surface. Any failure returns `Err` so the caller
/// can try the next backend rather than aborting the process.
fn try_backend(
window: &Arc<Window>,
backends: Option<wgpu::Backends>,
force_fallback_adapter: bool,
) -> Result<Self, RenderError> {
let size = window.inner_size();
// The window doubles as the display handle (needed by GL/X11-style
// backends); `from_env` keeps backend/flags overridable via WGPU_*.
let mut desc =
wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone()));
if let Some(backends) = backends {
desc.backends = backends;
}
let instance = wgpu::Instance::new(desc);
let surface = instance.create_surface(window.clone())?;
let gpu = Gpu::with_instance(instance, Some(&surface), force_fallback_adapter)?;
let config = surface
.get_default_config(gpu.adapter(), size.width.max(1), size.height.max(1))
.ok_or(RenderError::UnsupportedSurface)?;
configure_surface(gpu.device(), &surface, &config)?;
log::info!(
"surface configured: {}x{} {:?} ({:?}) on {:?}",
config.width,
config.height,
config.format,
config.present_mode,
gpu.adapter().get_info().backend,
);
Ok(Self {
gpu,
surface,
config,
clear_color: Color::BLACK,
})
}
/// Reconfigures the surface for a new window size. Zero dimensions
/// (minimized window) are clamped to 1 so the surface stays valid.
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(self.gpu.device(), &self.config);
}
/// Current surface size in physical pixels.
pub fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
/// The surface's texture format. Apps need this to build render pipelines
/// (or UI integrations) whose output matches the surface.
pub fn surface_format(&self) -> wgpu::TextureFormat {
self.config.format
}
/// The color the surface is cleared to each frame.
pub fn clear_color(&self) -> Color {
self.clear_color
}
/// Sets the clear color; takes effect on the next rendered frame.
pub fn set_clear_color(&mut self, color: Color) {
self.clear_color = color;
}
/// Renders one frame: acquires the next surface texture, clears it, and
/// presents. Equivalent to [`render_frame_with`](Self::render_frame_with)
/// with an empty draw hook.
pub fn render_frame(&mut self, window: &Window) -> Result<(), RenderError> {
self.render_frame_with(window, |_| {})
}
/// Renders one frame, invoking `draw` after the clear and before present.
///
/// The surface texture is acquired and cleared to
/// [`clear_color`](Self::clear_color), then `draw` is handed a
/// [`RenderCtx`] so it can record additional passes into the same view
/// (use `LoadOp::Load` to preserve the clear), and finally the frame is
/// presented.
///
/// Lost or outdated surfaces (e.g. mid-resize) are reconfigured and the
/// frame skipped; timed-out or occluded acquires skip the frame. All are
/// normal transient conditions and not reported as errors.
pub fn render_frame_with(
&mut self,
window: &Window,
draw: impl FnOnce(&RenderCtx<'_>),
) -> Result<(), RenderError> {
use wgpu::CurrentSurfaceTexture;
let frame = match self.surface.get_current_texture() {
// A suboptimal frame is still presentable; the next resize event
// reconfigures the surface anyway.
CurrentSurfaceTexture::Success(frame) | CurrentSurfaceTexture::Suboptimal(frame) => {
frame
}
CurrentSurfaceTexture::Lost | CurrentSurfaceTexture::Outdated => {
self.surface.configure(self.gpu.device(), &self.config);
return Ok(());
}
CurrentSurfaceTexture::Timeout | CurrentSurfaceTexture::Occluded => return Ok(()),
CurrentSurfaceTexture::Validation => return Err(RenderError::SurfaceValidation),
};
let view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
clear_view(self.gpu.device(), self.gpu.queue(), &view, self.clear_color);
let ctx = RenderCtx {
gpu: &self.gpu,
view: &view,
window,
surface_format: self.config.format,
size: (self.config.width, self.config.height),
};
draw(&ctx);
frame.present();
Ok(())
}
/// The underlying GPU handle.
pub fn gpu(&self) -> &Gpu {
&self.gpu
}
}
/// Configures `surface`, capturing any validation error instead of letting it
/// reach wgpu's default (fatal, process-aborting) error handler.
///
/// `surface.configure` returns `()` and reports failures through the device's
/// error sink, which by default panics. Wrapping it in a validation error scope
/// turns "Invalid surface" (and similar) into a recoverable [`Result`] so the
/// caller can fall back to another backend.
fn configure_surface(
device: &wgpu::Device,
surface: &wgpu::Surface<'static>,
config: &wgpu::SurfaceConfiguration,
) -> Result<(), RenderError> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
surface.configure(device, config);
// `pop()` consumes the guard and yields any captured error. On native
// backends the future is already resolved; `block_on` just unwraps it.
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(RenderError::SurfaceConfigure(err.to_string()));
}
Ok(())
}
+435
View File
@@ -0,0 +1,435 @@
//! [`ForwardRenderer`]: a single-pass forward renderer with a depth buffer and
//! one directional light.
//!
//! Stage 4 scope: draw a list of [`RenderObject`]s (each a [`GpuMesh`] +
//! [`Material`] + [`Transform`]) through the lit shader, into a caller-provided
//! color target, using an owned depth texture. Shadows, multiple lights, and
//! post-processing arrive in later stages.
use std::num::NonZeroU64;
use bytemuck::{Pod, Zeroable};
use glam::Mat3;
use serde::{Deserialize, Serialize};
use super::mesh::{GpuMesh, Vertex};
use super::{Camera, Material};
use crate::math::{Color, Transform, Vec3, Vec4};
/// Depth buffer format used by the forward pass.
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// A directional light: parallel rays with a travel `direction`.
///
/// Also a **reflected, addable component**: drop one on an entity to author a
/// sun/key light in the scene, dual-editable from the editor and scripts.
/// (Gathering light entities into the forward pass is a later-stage wiring; the
/// renderer currently takes its [`Lighting`] directly.)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, crate::reflect::Reflect)]
pub struct DirectionalLight {
/// The direction the light travels (does not need to be normalized).
pub direction: Vec3,
/// Light color.
pub color: Color,
/// Scalar intensity multiplier.
pub intensity: f32,
}
impl Default for DirectionalLight {
fn default() -> Self {
Self {
direction: Vec3::new(-0.5, -1.0, -0.35),
color: Color::WHITE,
intensity: 1.0,
}
}
}
/// Scene lighting for a forward pass: one directional light plus an ambient term.
#[derive(Debug, Clone, Copy)]
pub struct Lighting {
/// The single directional (sun) light.
pub light: DirectionalLight,
/// Flat ambient color added everywhere (cheap fill light).
pub ambient: Color,
}
impl Default for Lighting {
fn default() -> Self {
Self {
light: DirectionalLight::default(),
ambient: Color::rgb(0.08, 0.08, 0.10),
}
}
}
/// One drawable: a GPU mesh placed by `transform` and shaded with `material`.
pub struct RenderObject<'a> {
/// The mesh to draw.
pub mesh: &'a GpuMesh,
/// Its surface material.
pub material: Material,
/// World placement.
pub transform: Transform,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct GlobalsUniform {
view_proj: [[f32; 4]; 4],
camera_pos: [f32; 4],
light_dir: [f32; 4],
light_color: [f32; 4],
ambient: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct ObjectUniform {
model: [[f32; 4]; 4],
normal_mtx: [[f32; 4]; 4],
albedo: [f32; 4],
mr: [f32; 4],
}
/// A forward renderer owning its pipeline, depth buffer, and uniform storage.
pub struct ForwardRenderer {
pipeline: wgpu::RenderPipeline,
globals_buffer: wgpu::Buffer,
globals_bind_group: wgpu::BindGroup,
object_layout: wgpu::BindGroupLayout,
object_buffer: wgpu::Buffer,
object_bind_group: wgpu::BindGroup,
/// Per-object stride: `size_of::<ObjectUniform>` rounded up to the device's
/// minimum dynamic-uniform-buffer offset alignment.
object_stride: u64,
object_capacity: u32,
depth: Option<DepthTarget>,
color_format: wgpu::TextureFormat,
}
struct DepthTarget {
view: wgpu::TextureView,
width: u32,
height: u32,
}
impl ForwardRenderer {
/// Builds the renderer for a given color target format (e.g. the surface
/// format for a window, or `Rgba8Unorm` for offscreen rendering).
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("oxide.forward.lit"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/lit.wgsl").into()),
});
let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("oxide.forward.globals_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: NonZeroU64::new(std::mem::size_of::<GlobalsUniform>() as u64),
},
count: None,
}],
});
let object_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("oxide.forward.object_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("oxide.forward.pipeline_layout"),
bind_group_layouts: &[Some(&globals_layout), Some(&object_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("oxide.forward.pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Vertex::LAYOUT],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("oxide.forward.globals"),
size: std::mem::size_of::<GlobalsUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("oxide.forward.globals_bg"),
layout: &globals_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals_buffer.as_entire_binding(),
}],
});
let object_stride = align_up(
std::mem::size_of::<ObjectUniform>() as u64,
device.limits().min_uniform_buffer_offset_alignment as u64,
);
let object_capacity = 16;
let (object_buffer, object_bind_group) =
create_object_storage(device, &object_layout, object_stride, object_capacity);
Self {
pipeline,
globals_buffer,
globals_bind_group,
object_layout,
object_buffer,
object_bind_group,
object_stride,
object_capacity,
depth: None,
color_format,
}
}
/// The color target format this renderer was built for.
pub fn color_format(&self) -> wgpu::TextureFormat {
self.color_format
}
/// Renders `objects` into `target` (whose full physical size is
/// `width`×`height`) as seen by `camera` placed at `view_transform`, lit
/// by `lighting`. Drawing is restricted to `viewport_rect` (a sub-
/// rectangle of the target), and the projection uses that rect's aspect
/// ratio.
///
/// The color target is *loaded* (not cleared) so a clear pass run before
/// this — e.g. the window's clear color — shows through as the background;
/// the depth buffer is cleared to 1.0 each call.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
target: &wgpu::TextureView,
(width, height): (u32, u32),
viewport_rect: crate::math::Rect,
camera: &Camera,
view_transform: &Transform,
lighting: &Lighting,
objects: &[RenderObject<'_>],
) {
let (width, height) = (width.max(1), height.max(1));
// Clamp the viewport rect to the target so wgpu doesn't complain.
let vp_w = viewport_rect.width().max(1.0).min(width as f32);
let vp_h = viewport_rect.height().max(1.0).min(height as f32);
let vp_x = viewport_rect.min.x.max(0.0).min(width as f32 - vp_w);
let vp_y = viewport_rect.min.y.max(0.0).min(height as f32 - vp_h);
// Depth must match the full color target's dimensions (the
// attachment binding requires that). Pixels outside `set_viewport`
// are never written, so the extra depth is wasted memory but never
// incorrect.
self.ensure_depth(device, width, height);
self.ensure_object_capacity(device, objects.len() as u32);
// Globals — aspect comes from the viewport rect, not the target.
let aspect = vp_w / vp_h;
let view_proj = camera.view_projection(aspect, view_transform);
let to_light = (-lighting.light.direction).normalize_or_zero();
let lc = lighting.light.color;
let amb = lighting.ambient;
let globals = GlobalsUniform {
view_proj: view_proj.to_cols_array_2d(),
camera_pos: view_transform.translation.extend(1.0).to_array(),
light_dir: to_light.extend(0.0).to_array(),
light_color: (Vec4::new(lc.r, lc.g, lc.b, 1.0) * lighting.light.intensity).to_array(),
ambient: Vec4::new(amb.r, amb.g, amb.b, 1.0).to_array(),
};
queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
// Per-object uniforms.
for (i, obj) in objects.iter().enumerate() {
let model = obj.transform.to_matrix();
let normal_mtx = Mat3::from_mat4(model).inverse().transpose();
let normal_mtx4 = [
normal_mtx.x_axis.extend(0.0).to_array(),
normal_mtx.y_axis.extend(0.0).to_array(),
normal_mtx.z_axis.extend(0.0).to_array(),
[0.0, 0.0, 0.0, 1.0],
];
let a = obj.material.albedo;
let uniform = ObjectUniform {
model: model.to_cols_array_2d(),
normal_mtx: normal_mtx4,
albedo: [a.r, a.g, a.b, a.a],
mr: [obj.material.metallic, obj.material.roughness, 0.0, 0.0],
};
queue.write_buffer(
&self.object_buffer,
i as u64 * self.object_stride,
bytemuck::bytes_of(&uniform),
);
}
let depth_view = &self.depth.as_ref().expect("depth ensured above").view;
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("oxide.forward.encoder"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("oxide.forward.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// Restrict drawing to the host's viewport sub-rect. Pixels
// outside this rectangle keep whatever the prior pass (e.g.
// ClearPass or the window clear) wrote there.
pass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.globals_bind_group, &[]);
for (i, obj) in objects.iter().enumerate() {
let offset = (i as u64 * self.object_stride) as u32;
pass.set_bind_group(1, &self.object_bind_group, &[offset]);
pass.set_vertex_buffer(0, obj.mesh.vertex_buffer.slice(..));
pass.set_index_buffer(obj.mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..obj.mesh.index_count, 0, 0..1);
}
}
queue.submit([encoder.finish()]);
}
fn ensure_depth(&mut self, device: &wgpu::Device, width: u32, height: u32) {
let stale = match &self.depth {
Some(d) => d.width != width || d.height != height,
None => true,
};
if stale {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("oxide.forward.depth"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
self.depth = Some(DepthTarget {
view: texture.create_view(&wgpu::TextureViewDescriptor::default()),
width,
height,
});
}
}
fn ensure_object_capacity(&mut self, device: &wgpu::Device, needed: u32) {
if needed > self.object_capacity {
let capacity = needed.next_power_of_two();
let (buffer, bind_group) =
create_object_storage(device, &self.object_layout, self.object_stride, capacity);
self.object_buffer = buffer;
self.object_bind_group = bind_group;
self.object_capacity = capacity;
}
}
}
/// Allocates the per-object uniform buffer (`capacity` slots of `stride` bytes)
/// and a dynamic-offset bind group over it.
fn create_object_storage(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
stride: u64,
capacity: u32,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("oxide.forward.objects"),
size: stride * capacity as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("oxide.forward.object_bg"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &buffer,
offset: 0,
size: NonZeroU64::new(std::mem::size_of::<ObjectUniform>() as u64),
}),
}],
});
(buffer, bind_group)
}
/// Rounds `value` up to the next multiple of `align` (a power of two).
fn align_up(value: u64, align: u64) -> u64 {
let align = align.max(1);
value.div_ceil(align) * align
}
+94
View File
@@ -0,0 +1,94 @@
//! GPU acquisition: instance, adapter, device, queue.
use super::RenderError;
/// A handle to the GPU: instance, adapter, and the device/queue pair every
/// rendering operation goes through.
///
/// Created either for a window surface (via [`RenderContext`]) or headless
/// with [`Gpu::headless`] for offscreen rendering and tests.
///
/// [`RenderContext`]: super::RenderContext
pub struct Gpu {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
/// Acquires an adapter and device from an existing `instance`, preferring
/// an adapter that can present to `compatible_surface` when one is given.
///
/// `force_fallback_adapter` requests a software adapter (e.g. llvmpipe),
/// used as a last resort when no hardware adapter works.
pub(crate) fn with_instance(
instance: wgpu::Instance,
compatible_surface: Option<&wgpu::Surface<'_>>,
force_fallback_adapter: bool,
) -> Result<Self, RenderError> {
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter,
compatible_surface,
}))?;
log::info!(
"GPU adapter: {} ({:?})",
adapter.get_info().name,
adapter.get_info().backend
);
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("oxide.device"),
..Default::default()
}))?;
Ok(Self {
instance,
adapter,
device,
queue,
})
}
/// Acquires the GPU without any surface, for offscreen rendering and
/// automated tests.
///
/// Tries a hardware adapter first, then falls back to a software adapter
/// (e.g. llvmpipe) so headless rendering also works on machines without a
/// usable GPU.
pub fn headless() -> Result<Self, RenderError> {
// `from_env` keeps backend/flags overridable via WGPU_* env vars.
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
match Self::with_instance(instance, None, false) {
Ok(gpu) => Ok(gpu),
Err(hardware_err) => {
log::warn!("no hardware GPU adapter ({hardware_err}); trying software fallback");
let instance = wgpu::Instance::new(
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
);
Self::with_instance(instance, None, true)
}
}
}
/// The wgpu instance the adapter was created from.
pub fn instance(&self) -> &wgpu::Instance {
&self.instance
}
/// The physical adapter in use.
pub fn adapter(&self) -> &wgpu::Adapter {
&self.adapter
}
/// The logical device used to create GPU resources.
pub fn device(&self) -> &wgpu::Device {
&self.device
}
/// The queue used to submit command buffers.
pub fn queue(&self) -> &wgpu::Queue {
&self.queue
}
}
+51
View File
@@ -0,0 +1,51 @@
//! [`Material`]: a PBR-lite surface description.
//!
//! Stage 4 keeps materials to the parameters the basic lit pass consumes:
//! an albedo (base) color plus metallic/roughness factors. Textures, emissive,
//! and the full PBR set arrive with the shader system in a later stage.
use serde::{Deserialize, Serialize};
use crate::math::Color;
/// A PBR-lite material: base color and metallic/roughness factors.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Material {
/// Base (albedo) color, linear RGBA.
pub albedo: Color,
/// Metalness in `[0, 1]` (0 = dielectric, 1 = metal).
pub metallic: f32,
/// Perceptual roughness in `[0, 1]` (0 = mirror, 1 = fully rough).
pub roughness: f32,
}
impl Default for Material {
/// A neutral mid-gray dielectric.
fn default() -> Self {
Self {
albedo: Color::rgb(0.8, 0.8, 0.8),
metallic: 0.0,
roughness: 0.6,
}
}
}
impl Material {
/// A matte, non-metallic material of the given color.
pub fn diffuse(albedo: Color) -> Self {
Self {
albedo,
metallic: 0.0,
roughness: 0.9,
}
}
/// A metallic material of the given color and roughness.
pub fn metal(albedo: Color, roughness: f32) -> Self {
Self {
albedo,
metallic: 1.0,
roughness: roughness.clamp(0.0, 1.0),
}
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Mesh data: CPU-side [`Mesh`] geometry, its GPU upload ([`GpuMesh`]), and
//! built-in primitive builders.
//!
//! A [`Vertex`] carries position, normal, and UV — the minimal set the Stage 4
//! forward renderer needs for lit, textured-ready geometry. Meshes are built on
//! the CPU (procedurally or, later, from a GLTF import) and uploaded once into a
//! [`GpuMesh`] for drawing.
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::math::{Aabb, Vec2, Vec3};
/// A single mesh vertex: position, normal, and texture coordinate.
///
/// `repr(C)` + [`Pod`] so a `&[Vertex]` can be uploaded straight into a GPU
/// vertex buffer with no per-field marshalling.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
pub struct Vertex {
/// Object-space position.
pub position: [f32; 3],
/// Object-space normal (expected unit length for correct lighting).
pub normal: [f32; 3],
/// Texture coordinate.
pub uv: [f32; 2],
}
impl Vertex {
/// Builds a vertex from math types.
pub fn new(position: Vec3, normal: Vec3, uv: Vec2) -> Self {
Self {
position: position.to_array(),
normal: normal.to_array(),
uv: uv.to_array(),
}
}
/// The `wgpu` vertex buffer layout matching this struct's fields
/// (`@location(0)` position, `@location(1)` normal, `@location(2)` uv).
pub const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![
0 => Float32x3, // position
1 => Float32x3, // normal
2 => Float32x2, // uv
],
};
}
/// CPU-side mesh geometry: an indexed triangle list.
///
/// Indices are `u32` (32-bit), so meshes are not limited to 65k vertices.
#[derive(Debug, Clone, Default)]
pub struct Mesh {
/// Vertex data.
pub vertices: Vec<Vertex>,
/// Triangle indices into [`vertices`](Self::vertices), three per triangle.
pub indices: Vec<u32>,
}
impl Mesh {
/// Creates a mesh from raw vertex and index data.
pub fn new(vertices: Vec<Vertex>, indices: Vec<u32>) -> Self {
Self { vertices, indices }
}
/// Number of triangles (index count / 3).
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// The axis-aligned bounds of the mesh in object space
/// ([`Aabb::EMPTY`](crate::math::Aabb) for an empty mesh).
pub fn bounds(&self) -> Aabb {
Aabb::from_points(self.vertices.iter().map(|v| Vec3::from_array(v.position)))
}
/// Uploads the mesh into GPU vertex/index buffers for drawing.
pub fn upload(&self, device: &wgpu::Device, label: &str) -> GpuMesh {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("{label}.vertices")),
contents: bytemuck::cast_slice(&self.vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("{label}.indices")),
contents: bytemuck::cast_slice(&self.indices),
usage: wgpu::BufferUsages::INDEX,
});
GpuMesh {
vertex_buffer,
index_buffer,
index_count: self.indices.len() as u32,
}
}
/// A unit cube centered at the origin (side length 1), with per-face normals
/// and UVs (so each face is flat-shaded correctly).
pub fn cube() -> Self {
Self::box_mesh(Vec3::splat(1.0))
}
/// An axis-aligned box of the given `size` (full extents), centered at the
/// origin, with per-face normals and UVs.
pub fn box_mesh(size: Vec3) -> Self {
let h = size * 0.5;
// (normal, then the four corners CCW seen from outside)
let faces: [(Vec3, [Vec3; 4]); 6] = [
// +X
(
Vec3::X,
[
Vec3::new(h.x, -h.y, h.z),
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(h.x, h.y, -h.z),
Vec3::new(h.x, h.y, h.z),
],
),
// -X
(
Vec3::NEG_X,
[
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(-h.x, -h.y, h.z),
Vec3::new(-h.x, h.y, h.z),
Vec3::new(-h.x, h.y, -h.z),
],
),
// +Y
(
Vec3::Y,
[
Vec3::new(-h.x, h.y, h.z),
Vec3::new(h.x, h.y, h.z),
Vec3::new(h.x, h.y, -h.z),
Vec3::new(-h.x, h.y, -h.z),
],
),
// -Y
(
Vec3::NEG_Y,
[
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(h.x, -h.y, h.z),
Vec3::new(-h.x, -h.y, h.z),
],
),
// +Z
(
Vec3::Z,
[
Vec3::new(-h.x, -h.y, h.z),
Vec3::new(h.x, -h.y, h.z),
Vec3::new(h.x, h.y, h.z),
Vec3::new(-h.x, h.y, h.z),
],
),
// -Z
(
Vec3::NEG_Z,
[
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(-h.x, h.y, -h.z),
Vec3::new(h.x, h.y, -h.z),
],
),
];
let uvs = [
Vec2::new(0.0, 1.0),
Vec2::new(1.0, 1.0),
Vec2::new(1.0, 0.0),
Vec2::new(0.0, 0.0),
];
let mut vertices = Vec::with_capacity(24);
let mut indices = Vec::with_capacity(36);
for (normal, corners) in faces {
let base = vertices.len() as u32;
for (corner, uv) in corners.iter().zip(uvs.iter()) {
vertices.push(Vertex::new(*corner, normal, *uv));
}
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
}
Self::new(vertices, indices)
}
/// A flat plane of `size` units on the XZ axes, centered at the origin,
/// facing `+Y`. Useful as a ground reference.
pub fn plane(size: f32) -> Self {
let h = size * 0.5;
let n = Vec3::Y;
let vertices = vec![
Vertex::new(Vec3::new(-h, 0.0, h), n, Vec2::new(0.0, 1.0)),
Vertex::new(Vec3::new(h, 0.0, h), n, Vec2::new(1.0, 1.0)),
Vertex::new(Vec3::new(h, 0.0, -h), n, Vec2::new(1.0, 0.0)),
Vertex::new(Vec3::new(-h, 0.0, -h), n, Vec2::new(0.0, 0.0)),
];
Self::new(vertices, vec![0, 1, 2, 0, 2, 3])
}
/// A UV sphere of `radius` with `sectors` longitudinal and `stacks`
/// latitudinal divisions. Normals are the (normalized) positions.
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Self {
use std::f32::consts::PI;
let sectors = sectors.max(3);
let stacks = stacks.max(2);
let mut vertices = Vec::new();
for i in 0..=stacks {
// From +Y pole (phi=0) to -Y pole (phi=PI).
let phi = PI * i as f32 / stacks as f32;
let (sin_phi, cos_phi) = phi.sin_cos();
for j in 0..=sectors {
let theta = 2.0 * PI * j as f32 / sectors as f32;
let (sin_theta, cos_theta) = theta.sin_cos();
let dir = Vec3::new(sin_phi * cos_theta, cos_phi, sin_phi * sin_theta);
let uv = Vec2::new(j as f32 / sectors as f32, i as f32 / stacks as f32);
vertices.push(Vertex::new(dir * radius, dir, uv));
}
}
let mut indices = Vec::new();
let row = sectors + 1;
for i in 0..stacks {
for j in 0..sectors {
let a = i * row + j;
let b = a + row;
// Two triangles per quad; skip degenerate ones at the poles.
// Vertex order is `a → a+1 → b` and `a+1 → b+1 → b`, which
// winds the quad CCW when seen from *outside* the sphere —
// the wgpu front-face convention. The previous ordering
// (`a, b, a+1` / `a+1, b, b+1`) wound them CW from outside,
// which made back-face culling eat the sphere's surface and
// showed intersecting opaque meshes through it.
if i != 0 {
indices.extend_from_slice(&[a, a + 1, b]);
}
if i != stacks - 1 {
indices.extend_from_slice(&[a + 1, b + 1, b]);
}
}
}
Self::new(vertices, indices)
}
}
/// A mesh uploaded to the GPU: vertex and index buffers ready to draw.
pub struct GpuMesh {
/// Vertex buffer, laid out per [`Vertex::LAYOUT`].
pub vertex_buffer: wgpu::Buffer,
/// `u32` index buffer.
pub index_buffer: wgpu::Buffer,
/// Number of indices to draw.
pub index_count: u32,
}
+111
View File
@@ -0,0 +1,111 @@
//! GPU rendering infrastructure.
//!
//! Stage 2 acquired a GPU ([`Gpu`]), drove a window surface ([`RenderContext`]),
//! and cleared it each frame. Stage 4 adds mesh rendering: build geometry
//! ([`Mesh`]/[`Vertex`]), upload it ([`GpuMesh`]), describe surfaces with a
//! [`Material`], place a [`Camera`], and draw through the [`ForwardRenderer`].
mod camera;
mod context;
mod forward;
mod gpu;
mod material;
mod mesh;
mod pipeline;
mod renderable;
mod ui_pass;
pub use camera::Camera;
pub use context::RenderContext;
pub use forward::{DirectionalLight, ForwardRenderer, Lighting, RenderObject, DEPTH_FORMAT};
pub use gpu::Gpu;
pub use material::Material;
pub use mesh::{GpuMesh, Mesh, Vertex};
pub use pipeline::{ClearPass, ForwardPass, FrameContext, RenderPass, RenderPipeline};
pub use renderable::{MeshRenderer, PrimitiveShape};
pub use ui_pass::{UiBatch, UiOverlayPass};
use crate::math::Color;
/// Errors produced by the rendering layer.
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
/// No GPU adapter compatible with the requested surface (or headless use)
/// was found on this system.
#[error("no compatible GPU adapter found: {0}")]
NoAdapter(#[from] wgpu::RequestAdapterError),
/// The adapter was found but refused to provide a device.
#[error("failed to request GPU device: {0}")]
Device(#[from] wgpu::RequestDeviceError),
/// The window surface could not be created.
#[error("failed to create surface: {0}")]
CreateSurface(#[from] wgpu::CreateSurfaceError),
/// The adapter cannot present to the created surface.
#[error("the GPU adapter does not support presenting to this surface")]
UnsupportedSurface,
/// Configuring the surface raised a validation error. On some drivers a
/// backend reports a GPU but cannot actually present to the window surface
/// (e.g. old NVIDIA on Wayland under Vulkan); this is caught so the engine
/// can fall back to another backend instead of aborting.
#[error("surface configuration failed: {0}")]
SurfaceConfigure(String),
/// Every render backend/adapter the engine tried failed to produce a
/// working surface — no usable GPU path on this system.
#[error("no working render backend found (tried Vulkan/Metal/DX12, GL, and software)")]
NoWorkingBackend,
/// Acquiring the next frame raised a validation error — a bug in surface
/// configuration, not a transient condition.
#[error("surface frame acquisition failed validation")]
SurfaceValidation,
}
/// Records and submits a render pass that clears `view` to `color`.
///
/// This is the whole of Stage 2's rendering: both the windowed
/// [`RenderContext`] and offscreen targets (e.g. tests) clear through here.
pub fn clear_view(
device: &wgpu::Device,
queue: &wgpu::Queue,
view: &wgpu::TextureView,
color: Color,
) {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("oxide.clear"),
});
// The pass is dropped immediately: a load-op clear with no draws is all
// that is needed to fill the target.
let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("oxide.clear.pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(to_wgpu_color(color)),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
queue.submit([encoder.finish()]);
}
/// Converts the engine's [`Color`] (linear `f32`) to a [`wgpu::Color`]
/// (linear `f64`), as used by clear operations.
pub fn to_wgpu_color(color: Color) -> wgpu::Color {
wgpu::Color {
r: color.r as f64,
g: color.g as f64,
b: color.b as f64,
a: color.a as f64,
}
}
+233
View File
@@ -0,0 +1,233 @@
//! [`RenderPipeline`]: a data-driven, ordered list of composable render passes.
//!
//! Stage 4's renderer drew everything in one hardcoded pass. Stage 5 generalizes
//! that into a list of named [`RenderPass`]es that share one frame's targets and
//! run in order. 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) versus a full realistic stack, paying only for the
//! passes turned on.
//!
//! The Stage-4 forward pass is retrofitted onto this as [`ForwardPass`], so the
//! default pipeline ([`RenderPipeline::forward`]) is just `[Clear, Forward]` and
//! produces pixel-identical output. Later stages add passes (shadows,
//! post-process, overlay UI) **without touching the renderer core** — they
//! register a pass.
use crate::math::{Color, Rect, Transform, Vec2};
use super::{clear_view, Camera, ForwardRenderer, Lighting, RenderObject};
/// Everything one frame's passes operate on: the shared color target and the
/// scene view to draw.
///
/// Passes share the same `color` target (and, as the pipeline grows, depth and
/// intermediate textures), which is what makes them *composable*: a clear pass
/// fills the target, the forward pass draws into it, a future post pass reads and
/// rewrites it.
pub struct FrameContext<'a> {
/// The GPU device.
pub device: &'a wgpu::Device,
/// The GPU queue.
pub queue: &'a wgpu::Queue,
/// The color target every pass renders into.
pub color: &'a wgpu::TextureView,
/// Target size in physical pixels (the whole color target the pipeline
/// is writing into).
pub size: (u32, u32),
/// The sub-rectangle of the target that drawing is restricted to, in
/// physical pixels (`min` = upper-left, `max` = lower-right). Passes
/// configure the wgpu viewport from this and the camera uses its
/// aspect ratio for the projection.
///
/// `None` means "use the full target" — the default for headless tests
/// and for hosts that render to a whole window. The editor sets this to
/// the Viewport tab's rect from the docking shell so picking and
/// projection align with what the user sees inside the tab rather than
/// stretching across the whole window.
pub viewport_rect: Option<Rect>,
/// The background clear color (used by [`ClearPass`]).
pub clear_color: Color,
/// The camera to render from.
pub camera: &'a Camera,
/// The camera's world placement.
pub view_transform: &'a Transform,
/// Scene lighting.
pub lighting: &'a Lighting,
/// The drawables, already culled by the host (e.g. by camera
/// [`visibility`](Camera::visibility)).
pub objects: &'a [RenderObject<'a>],
}
impl FrameContext<'_> {
/// The viewport rect [`viewport_rect`](Self::viewport_rect) resolves to —
/// the explicit sub-rect when set, otherwise the full target.
pub fn resolved_viewport(&self) -> Rect {
self.viewport_rect.unwrap_or_else(|| {
Rect::from_min_size(
Vec2::ZERO,
Vec2::new(self.size.0.max(1) as f32, self.size.1.max(1) as f32),
)
})
}
}
/// One stage of the frame. Implement this to add a custom pass; register it on a
/// [`RenderPipeline`]. Passes are owned by the pipeline and run in order.
pub trait RenderPass {
/// Records this pass's GPU work for the frame.
fn run(&mut self, frame: &mut FrameContext<'_>);
}
struct PassEntry {
name: String,
enabled: bool,
pass: Box<dyn RenderPass>,
}
/// An ordered, named list of render passes.
///
/// Add passes with [`add_pass`](Self::add_pass), toggle them with
/// [`set_enabled`](Self::set_enabled), or drop them with [`remove`](Self::remove)
/// — all without touching any pass's implementation. [`render`](Self::render)
/// runs every enabled pass in order against one [`FrameContext`].
#[derive(Default)]
pub struct RenderPipeline {
passes: Vec<PassEntry>,
}
impl RenderPipeline {
/// An empty pipeline (no passes).
pub fn new() -> Self {
Self::default()
}
/// The default forward pipeline: a [`ClearPass`] followed by a
/// [`ForwardPass`]. Pixel-identical to the Stage-4 renderer's output.
pub fn forward(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let mut pipeline = Self::new();
pipeline.add_pass("clear", ClearPass);
pipeline.add_pass("forward", ForwardPass::new(device, color_format));
pipeline
}
/// Appends a named pass (enabled). Replaces any existing pass with the same
/// name, keeping its position.
pub fn add_pass(&mut self, name: impl Into<String>, pass: impl RenderPass + 'static) {
let name = name.into();
let entry = PassEntry {
name: name.clone(),
enabled: true,
pass: Box::new(pass),
};
match self.passes.iter_mut().find(|e| e.name == name) {
Some(existing) => *existing = entry,
None => self.passes.push(entry),
}
}
/// Inserts a pass before the pass named `before` (or at the end if not
/// found). Useful for slotting a post effect into a fixed position.
pub fn insert_before(
&mut self,
before: &str,
name: impl Into<String>,
pass: impl RenderPass + 'static,
) {
let entry = PassEntry {
name: name.into(),
enabled: true,
pass: Box::new(pass),
};
match self.passes.iter().position(|e| e.name == before) {
Some(index) => self.passes.insert(index, entry),
None => self.passes.push(entry),
}
}
/// Enables or disables the named pass. Returns whether it exists.
pub fn set_enabled(&mut self, name: &str, enabled: bool) -> bool {
match self.passes.iter_mut().find(|e| e.name == name) {
Some(entry) => {
entry.enabled = enabled;
true
}
None => false,
}
}
/// Removes the named pass. Returns whether it existed.
pub fn remove(&mut self, name: &str) -> bool {
let before = self.passes.len();
self.passes.retain(|e| e.name != name);
self.passes.len() != before
}
/// Whether a pass with this name is registered.
pub fn has_pass(&self, name: &str) -> bool {
self.passes.iter().any(|e| e.name == name)
}
/// The pass names in execution order.
pub fn pass_names(&self) -> impl Iterator<Item = &str> {
self.passes.iter().map(|e| e.name.as_str())
}
/// Runs every enabled pass in order against `frame`.
pub fn render(&mut self, frame: &mut FrameContext<'_>) {
for entry in &mut self.passes {
if entry.enabled {
entry.pass.run(frame);
}
}
}
}
/// A pass that clears the color target to [`FrameContext::clear_color`].
///
/// Conventionally the first pass, so later passes load over the cleared
/// background (matching the Stage-4 clear-then-draw flow).
pub struct ClearPass;
impl RenderPass for ClearPass {
fn run(&mut self, frame: &mut FrameContext<'_>) {
clear_view(frame.device, frame.queue, frame.color, frame.clear_color);
}
}
/// A pass that draws the frame's objects with the lit forward renderer.
///
/// Wraps the Stage-4 [`ForwardRenderer`]; the color target is *loaded* (so a
/// preceding [`ClearPass`] shows through), depth is managed internally.
pub struct ForwardPass {
renderer: ForwardRenderer,
}
impl ForwardPass {
/// Builds a forward pass for the given color target format.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
Self {
renderer: ForwardRenderer::new(device, color_format),
}
}
/// The wrapped renderer's color format.
pub fn color_format(&self) -> wgpu::TextureFormat {
self.renderer.color_format()
}
}
impl RenderPass for ForwardPass {
fn run(&mut self, frame: &mut FrameContext<'_>) {
self.renderer.render(
frame.device,
frame.queue,
frame.color,
frame.size,
frame.resolved_viewport(),
frame.camera,
frame.view_transform,
frame.lighting,
frame.objects,
);
}
}

Some files were not shown because too many files have changed in this diff Show More