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 f56a1eea3b
128 changed files with 40493 additions and 2 deletions
+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,
);
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Renderable scene components: [`MeshRenderer`] and [`PrimitiveShape`].
//!
//! A [`MeshRenderer`] is the component that makes a scene entity show up in the
//! 3D viewport: it pairs a mesh source with a [`Material`]. Stage 4 ships the
//! built-in [`PrimitiveShape`] source (cube/sphere/plane) — lightweight and
//! serializable, so the editor (and later scripts/AI agents) can author what an
//! entity renders. Imported meshes attach later via a mesh-asset handle.
use serde::{Deserialize, Serialize};
use super::{Material, Mesh};
use crate::math::{Aabb, Vec3};
/// A built-in primitive mesh an entity can render.
///
/// This names a shape rather than embedding vertex data, so it stays tiny,
/// serializable, and cheap to edit; the renderer resolves it to a (cached)
/// [`Mesh`]/GPU buffer.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Default,
Serialize,
Deserialize,
crate::reflect::ReflectEnum,
)]
pub enum PrimitiveShape {
/// Unit cube centered at the origin.
#[default]
Cube,
/// Unit-radius UV sphere.
Sphere,
/// A 1×1 ground plane on the XZ axes, facing `+Y`.
Plane,
}
impl PrimitiveShape {
/// All shapes, for building caches / editor menus.
pub const ALL: [PrimitiveShape; 3] = [
PrimitiveShape::Cube,
PrimitiveShape::Sphere,
PrimitiveShape::Plane,
];
/// A human-readable label.
pub fn label(self) -> &'static str {
match self {
PrimitiveShape::Cube => "Cube",
PrimitiveShape::Sphere => "Sphere",
PrimitiveShape::Plane => "Plane",
}
}
/// Builds the CPU [`Mesh`] for this shape.
pub fn mesh(self) -> Mesh {
match self {
PrimitiveShape::Cube => Mesh::cube(),
PrimitiveShape::Sphere => Mesh::uv_sphere(1.0, 32, 16),
PrimitiveShape::Plane => Mesh::plane(1.0),
}
}
/// The object-space bounds of this shape, without building a mesh — used for
/// ray-picking and culling.
pub fn local_bounds(self) -> Aabb {
let half = match self {
PrimitiveShape::Cube => Vec3::splat(0.5),
PrimitiveShape::Sphere => Vec3::ONE,
PrimitiveShape::Plane => Vec3::new(0.5, 0.0, 0.5),
};
Aabb::from_center_half_extents(Vec3::ZERO, half)
}
}
/// Component: what an entity renders.
///
/// Attach to a scene entity (via the ECS) to make it appear in a forward pass.
/// Stage 4 sources the mesh from a [`PrimitiveShape`]; the [`Material`] is
/// edited in the inspector. Both are serializable, supporting the engine's
/// dual-editable (editor + script/AI) component goal.
#[derive(
Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, crate::reflect::Reflect,
)]
pub struct MeshRenderer {
/// The mesh to draw.
pub shape: PrimitiveShape,
/// The surface material.
pub material: Material,
}
impl MeshRenderer {
/// A renderer for `shape` with the default material.
pub fn new(shape: PrimitiveShape) -> Self {
Self {
shape,
material: Material::default(),
}
}
/// A renderer for `shape` with an explicit `material`.
pub fn with_material(shape: PrimitiveShape, material: Material) -> Self {
Self { shape, material }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Color;
#[test]
fn every_shape_builds_a_nonempty_mesh() {
for shape in PrimitiveShape::ALL {
let mesh = shape.mesh();
assert!(!mesh.vertices.is_empty(), "{shape:?} has no vertices");
assert!(mesh.triangle_count() > 0, "{shape:?} has no triangles");
}
}
#[test]
fn mesh_renderer_round_trips_through_ron() {
let mr = MeshRenderer::with_material(
PrimitiveShape::Sphere,
Material::metal(Color::rgb(0.2, 0.4, 0.8), 0.25),
);
let ron = ron::to_string(&mr).unwrap();
let back: MeshRenderer = ron::from_str(&ron).unwrap();
assert_eq!(mr, back);
}
}
+69
View File
@@ -0,0 +1,69 @@
// Stage 4 forward lit shader: a single directional light with Lambert diffuse,
// ambient, and a Blinn-Phong specular term scaled by material roughness/metallic
// (PBR-lite). Output is linear color; an sRGB surface format converts on write.
struct Globals {
view_proj: mat4x4<f32>,
camera_pos: vec4<f32>, // xyz world-space camera position
light_dir: vec4<f32>, // xyz unit vector pointing TOWARD the light
light_color: vec4<f32>, // rgb light color * intensity
ambient: vec4<f32>, // rgb ambient term
};
struct ObjectData {
model: mat4x4<f32>,
normal_mtx: mat4x4<f32>, // inverse-transpose of model (3x3 in a 4x4)
albedo: vec4<f32>,
mr: vec4<f32>, // x = metallic, y = roughness
};
@group(0) @binding(0) var<uniform> globals: Globals;
@group(1) @binding(0) var<uniform> obj: ObjectData;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) world_normal: vec3<f32>,
@location(2) uv: vec2<f32>,
};
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
) -> VsOut {
let world = obj.model * vec4<f32>(position, 1.0);
var out: VsOut;
out.world_pos = world.xyz;
out.world_normal = (obj.normal_mtx * vec4<f32>(normal, 0.0)).xyz;
out.uv = uv;
out.clip_pos = globals.view_proj * world;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let n = normalize(in.world_normal);
let l = normalize(globals.light_dir.xyz);
let v = normalize(globals.camera_pos.xyz - in.world_pos);
let h = normalize(l + v);
let albedo = obj.albedo.rgb;
let metallic = obj.mr.x;
let roughness = clamp(obj.mr.y, 0.04, 1.0);
let ndl = max(dot(n, l), 0.0);
let ndh = max(dot(n, h), 0.0);
// Metals have no diffuse; dielectrics get a fixed 0.04 specular, metals
// tint their specular by the albedo.
let diffuse = albedo * (1.0 - metallic);
let spec_color = mix(vec3<f32>(0.04), albedo, metallic);
let spec_power = mix(8.0, 256.0, 1.0 - roughness);
let spec = spec_color * pow(ndh, spec_power) * select(0.0, 1.0, ndl > 0.0);
let direct = (diffuse * ndl + spec) * globals.light_color.rgb;
let ambient = albedo * globals.ambient.rgb;
return vec4<f32>(ambient + direct, obj.albedo.a);
}
+47
View File
@@ -0,0 +1,47 @@
// Oxide Stage-8 UI overlay shader.
//
// One vertex format covers both solid quads and glyph quads: the sentinel UV
// `(-1, -1)` marks "solid color, do not sample the atlas". This avoids
// branching on a separate flag attribute and keeps the vertex stride tight
// (32 bytes — pos2 + uv2 + color4).
struct Uniforms {
mvp: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
struct VsIn {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
};
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
};
@vertex
fn vs_main(in: VsIn) -> VsOut {
var out: VsOut;
out.clip_pos = u.mvp * vec4<f32>(in.position, 0.0, 1.0);
out.uv = in.uv;
out.color = in.color;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// Solid quads use the sentinel UV (-1, -1). Sampling out-of-range would
// be clamped or wrapped depending on the sampler, but we cheaply detect
// it instead so a single texture binding serves every primitive.
if (in.uv.x < 0.0) {
return in.color;
}
let alpha = textureSample(atlas, atlas_sampler, in.uv).r;
return vec4<f32>(in.color.rgb, in.color.a * alpha);
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
//! [`DisabledComponents`]: a hidden per-entity set of disabled component names.
//!
//! Some game-objects need a component *attached but not active* — e.g. a
//! camera that defaults disabled and a script turns it on at a trigger. ECS
//! component-sets don't carry an "active" bit per component on their own, so
//! this component stores the set of *type names* (matching the reflection
//! registry) that should be skipped by systems on this entity.
//!
//! - Each engine system that runs on a per-entity component query consults
//! [`Scene::is_component_disabled`](crate::scene::Scene::is_component_disabled)
//! (or this component directly) before acting; it's the Unity
//! "Component.enabled" equivalent in an archetypal ECS.
//! - The editor inspector reads + writes it through a per-component
//! checkbox, hides the component itself from view (it's metadata, not
//! authored data), and copies the set on Duplicate.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
/// Per-entity set of *disabled* component type names.
///
/// Names match the reflection registry (e.g. `"MeshRenderer"`). An absent
/// component (or an empty set) means every component on the entity is active.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DisabledComponents {
/// Disabled component type names. Stored as `String` so the data
/// round-trips through RON without the `&'static str` reference issue.
pub disabled: HashSet<String>,
}
impl DisabledComponents {
/// An empty set — every component is active.
pub fn new() -> Self {
Self::default()
}
/// Whether the component with this type name is disabled.
pub fn is_disabled(&self, type_name: &str) -> bool {
self.disabled.contains(type_name)
}
/// Marks the component disabled (`true`) or active (`false`). Adds or
/// removes the entry as needed.
pub fn set_disabled(&mut self, type_name: &str, disabled: bool) {
if disabled {
self.disabled.insert(type_name.to_string());
} else {
self.disabled.remove(type_name);
}
}
/// True if no components are currently disabled — a hint to systems that
/// the entire `DisabledComponents` component can be removed.
pub fn is_empty(&self) -> bool {
self.disabled.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_empty_and_disables_nothing() {
let d = DisabledComponents::new();
assert!(d.is_empty());
assert!(!d.is_disabled("MeshRenderer"));
}
#[test]
fn set_disabled_toggles_membership() {
let mut d = DisabledComponents::new();
d.set_disabled("MeshRenderer", true);
assert!(d.is_disabled("MeshRenderer"));
assert!(!d.is_empty());
// Idempotent.
d.set_disabled("MeshRenderer", true);
assert_eq!(d.disabled.len(), 1);
// Re-enable removes the entry.
d.set_disabled("MeshRenderer", false);
assert!(!d.is_disabled("MeshRenderer"));
assert!(d.is_empty());
}
#[test]
fn round_trips_through_ron() {
let mut d = DisabledComponents::new();
d.set_disabled("MeshRenderer", true);
d.set_disabled("RigidBody", true);
let text = ron::to_string(&d).unwrap();
let back: DisabledComponents = ron::from_str(&text).unwrap();
assert_eq!(back, d);
}
}
+722
View File
@@ -0,0 +1,722 @@
//! The [`Scene`]: entities, their components, and a transform hierarchy.
use std::collections::HashMap;
use hecs::{Component, Entity, World};
use super::SceneError;
use crate::math::Transform;
use crate::scene::node::Node;
/// What happens to an entity's children when it is despawned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DespawnPolicy {
/// Despawn the entity together with its entire subtree.
Recursive,
/// Despawn only the entity; reparent each child to the entity's parent,
/// promoting them to roots if the entity was itself a root.
DetachChildren,
}
/// A scene graph: a [`hecs`] world plus a parent/child transform hierarchy.
///
/// Entities are [`hecs::Entity`] handles. Every entity created through the
/// scene carries a [`Node`] and a (local) [`Transform`]; arbitrary additional
/// components can be attached via [`world_mut`](Self::world_mut) for the
/// systems added in later stages (meshes, rigid bodies, …).
///
/// The hierarchy is owned by the scene rather than stored as components, which
/// keeps child ordering deterministic (important for serialization and the
/// editor) and lets reparenting avoid archetype churn. Local transforms are the
/// authored values; [`world_transform`](Self::world_transform) and
/// [`world_transforms`](Self::world_transforms) resolve them against the
/// hierarchy as `parent_world * local`.
#[derive(Default)]
pub struct Scene {
world: World,
/// Top-level entities, in insertion order.
roots: Vec<Entity>,
/// Child lists keyed by parent, each in insertion order. Entities with no
/// children may be absent.
children: HashMap<Entity, Vec<Entity>>,
/// Upward links. Roots are absent from this map.
parents: HashMap<Entity, Entity>,
}
impl Scene {
/// Creates an empty scene.
pub fn new() -> Self {
Self::default()
}
// --- Lifecycle ---------------------------------------------------------
/// Spawns a new root entity carrying `node` and `transform`.
///
/// Every spawned entity automatically carries the three *node-baked*
/// components: [`Node`], [`Transform`], and
/// [`Layer`](crate::layer::Layer) (membership in the default layer).
/// They are inherent to being an entity in this scene — single-instance,
/// not added through the editor's "Add Component" menu, not removable.
/// Modular components (`MeshRenderer`, future colliders, scripts, …) are
/// attached on top.
pub fn spawn(&mut self, node: impl Into<Node>, transform: Transform) -> Entity {
let entity = self
.world
.spawn((node.into(), transform, crate::layer::Layer::default()));
self.roots.push(entity);
entity
}
/// Spawns a new entity as a child of `parent`. Auto-attaches the same
/// node-baked components as [`spawn`](Self::spawn).
///
/// # Panics
/// Panics if `parent` is not a live entity in this scene.
pub fn spawn_child(
&mut self,
parent: Entity,
node: impl Into<Node>,
transform: Transform,
) -> Entity {
assert!(
self.world.contains(parent),
"spawn_child: parent {parent:?} is not a live entity in this scene"
);
let entity = self
.world
.spawn((node.into(), transform, crate::layer::Layer::default()));
self.parents.insert(entity, parent);
self.children.entry(parent).or_default().push(entity);
entity
}
/// Despawns `entity`, handling its children according to `policy`.
///
/// Returns `true` if the entity existed and was removed.
pub fn despawn(&mut self, entity: Entity, policy: DespawnPolicy) -> bool {
if !self.world.contains(entity) {
return false;
}
// Remember the parent before unlinking, so DetachChildren can promote
// the orphans to the right place.
let grandparent = self.parents.get(&entity).copied();
self.unlink(entity);
match policy {
DespawnPolicy::Recursive => self.despawn_recursive(entity),
DespawnPolicy::DetachChildren => {
let kids = self.children.remove(&entity).unwrap_or_default();
let _ = self.world.despawn(entity);
for kid in kids {
match grandparent {
Some(gp) => {
self.parents.insert(kid, gp);
self.children.entry(gp).or_default().push(kid);
}
None => {
self.parents.remove(&kid);
self.roots.push(kid);
}
}
}
}
}
true
}
/// Recursively despawns `entity` and everything beneath it. Assumes
/// `entity` has already been unlinked from its parent / the root list.
fn despawn_recursive(&mut self, entity: Entity) {
let kids = self.children.remove(&entity).unwrap_or_default();
self.parents.remove(&entity);
let _ = self.world.despawn(entity);
for kid in kids {
self.despawn_recursive(kid);
}
}
/// Removes `entity` from its parent's child list (or the root list) and
/// from the parent map, without touching the entity itself.
fn unlink(&mut self, entity: Entity) {
match self.parents.remove(&entity) {
Some(parent) => {
if let Some(siblings) = self.children.get_mut(&parent) {
siblings.retain(|&e| e != entity);
}
}
None => self.roots.retain(|&e| e != entity),
}
}
// --- Hierarchy ---------------------------------------------------------
/// Reparents `entity` under `new_parent`, or makes it a root when
/// `new_parent` is `None`. Child ordering places `entity` last among its
/// new siblings.
///
/// Local transforms are preserved as-is (this does not compensate to keep
/// the world transform fixed).
///
/// # Errors
/// - [`SceneError::NoSuchEntity`] if `entity` or `new_parent` is not live.
/// - [`SceneError::WouldCycle`] if `new_parent` is `entity` itself or one
/// of its descendants.
pub fn set_parent(
&mut self,
entity: Entity,
new_parent: Option<Entity>,
) -> Result<(), SceneError> {
if !self.world.contains(entity) {
return Err(SceneError::NoSuchEntity);
}
if let Some(parent) = new_parent {
if !self.world.contains(parent) {
return Err(SceneError::NoSuchEntity);
}
// Walking up from the prospective parent must not reach `entity`,
// otherwise the link would form a cycle.
if parent == entity || self.is_ancestor(entity, parent) {
return Err(SceneError::WouldCycle);
}
}
self.unlink(entity);
match new_parent {
Some(parent) => {
self.parents.insert(entity, parent);
self.children.entry(parent).or_default().push(entity);
}
None => self.roots.push(entity),
}
Ok(())
}
/// Moves `entity` under `new_parent` (or to the root level when `None`),
/// positioned **immediately before** sibling `before`. If `before` is
/// `None` or isn't a child of the target, `entity` is appended.
///
/// Unlike [`set_parent`](Self::set_parent) (which always appends), this
/// controls the sibling order, so it covers both reparenting *and*
/// reordering within the same parent — the operation a hierarchy
/// drag-and-drop with an insertion indicator needs. The position is
/// resolved *after* unlinking `entity`, so reordering within one parent
/// doesn't suffer an off-by-one. Rejects cycles like `set_parent`.
pub fn reorder(
&mut self,
entity: Entity,
new_parent: Option<Entity>,
before: Option<Entity>,
) -> Result<(), SceneError> {
if !self.world.contains(entity) {
return Err(SceneError::NoSuchEntity);
}
if let Some(parent) = new_parent {
if !self.world.contains(parent) {
return Err(SceneError::NoSuchEntity);
}
if parent == entity || self.is_ancestor(entity, parent) {
return Err(SceneError::WouldCycle);
}
}
self.unlink(entity);
let siblings = match new_parent {
Some(parent) => {
self.parents.insert(entity, parent);
self.children.entry(parent).or_default()
}
None => &mut self.roots,
};
let index = before
.and_then(|b| siblings.iter().position(|&e| e == b))
.unwrap_or(siblings.len());
siblings.insert(index, entity);
Ok(())
}
/// Returns `true` if `ancestor` lies on the parent chain above `entity`.
fn is_ancestor(&self, ancestor: Entity, entity: Entity) -> bool {
let mut cursor = self.parents.get(&entity).copied();
while let Some(p) = cursor {
if p == ancestor {
return true;
}
cursor = self.parents.get(&p).copied();
}
false
}
/// The parent of `entity`, or `None` if it is a root or absent.
pub fn parent(&self, entity: Entity) -> Option<Entity> {
self.parents.get(&entity).copied()
}
/// The direct children of `entity`, in order. Empty for leaves.
pub fn children(&self, entity: Entity) -> &[Entity] {
self.children.get(&entity).map_or(&[], Vec::as_slice)
}
/// The top-level entities, in insertion order.
pub fn roots(&self) -> &[Entity] {
&self.roots
}
// --- Component access --------------------------------------------------
/// The node name, or `None` if `entity` is not live.
pub fn name(&self, entity: Entity) -> Option<String> {
self.world.get::<&Node>(entity).ok().map(|n| n.name.clone())
}
/// Renames `entity`. Returns `false` if it is not live.
pub fn set_name(&mut self, entity: Entity, name: impl Into<String>) -> bool {
match self.world.get::<&mut Node>(entity) {
Ok(mut node) => {
node.name = name.into();
true
}
Err(_) => false,
}
}
/// Whether `entity` is enabled, or `None` if it is not live.
pub fn is_enabled(&self, entity: Entity) -> Option<bool> {
self.world.get::<&Node>(entity).ok().map(|n| n.enabled)
}
/// Sets the enabled flag on `entity`. Returns `false` if it is not live.
pub fn set_enabled(&mut self, entity: Entity, enabled: bool) -> bool {
match self.world.get::<&mut Node>(entity) {
Ok(mut node) => {
node.enabled = enabled;
true
}
Err(_) => false,
}
}
/// Whether the named component on `entity` is marked disabled by a
/// [`DisabledComponents`](crate::scene::DisabledComponents) component.
/// Defaults to `false` when no `DisabledComponents` is attached.
///
/// Systems that act on a per-entity component query check this to honor
/// "attached but inactive" — the ECS equivalent of Unity's
/// `Component.enabled = false`.
pub fn is_component_disabled(&self, entity: hecs::Entity, type_name: &str) -> bool {
self.world
.get::<&super::DisabledComponents>(entity)
.ok()
.map(|d| d.is_disabled(type_name))
.unwrap_or(false)
}
/// Whether `entity` is enabled **and every ancestor is enabled** — its
/// effective state in the hierarchy. `None` if it is not live.
///
/// [`is_enabled`](Self::is_enabled) reports an entity's own authored flag;
/// this reports whether it is actually active, since disabling a node
/// disables its whole subtree (rendering, physics, audio, and queries skip
/// effectively-disabled entities). This is the Unity/Godot
/// `activeInHierarchy` distinction: the per-node flag is what you author,
/// the effective value is what systems honor.
pub fn is_effectively_enabled(&self, entity: Entity) -> Option<bool> {
if !self.contains(entity) {
return None;
}
let mut current = Some(entity);
while let Some(e) = current {
if !self.is_enabled(e).unwrap_or(true) {
return Some(false);
}
current = self.parent(e);
}
Some(true)
}
/// The authored (local) transform of `entity`, or `None` if not live.
pub fn local_transform(&self, entity: Entity) -> Option<Transform> {
self.world.get::<&Transform>(entity).ok().map(|t| *t)
}
/// Sets the local transform of `entity`. Returns `false` if not live.
pub fn set_local_transform(&mut self, entity: Entity, transform: Transform) -> bool {
match self.world.get::<&mut Transform>(entity) {
Ok(mut t) => {
*t = transform;
true
}
Err(_) => false,
}
}
// --- World transforms --------------------------------------------------
/// Resolves the world-space transform of a single `entity` by composing
/// local transforms up the parent chain. `None` if `entity` is not live.
///
/// For resolving many entities at once, prefer
/// [`world_transforms`](Self::world_transforms), which is a single pass.
pub fn world_transform(&self, entity: Entity) -> Option<Transform> {
let local = self.local_transform(entity)?;
match self.parents.get(&entity) {
Some(&parent) => Some(self.world_transform(parent)?.mul_transform(&local)),
None => Some(local),
}
}
/// Resolves world-space transforms for every entity in the scene in a
/// single top-down pass (`parent_world * local`).
pub fn world_transforms(&self) -> HashMap<Entity, Transform> {
let mut out = HashMap::with_capacity(self.len());
// Depth-first from each root, carrying the accumulated parent world
// transform down the stack.
let mut stack: Vec<(Entity, Transform)> = Vec::new();
for &root in &self.roots {
if let Some(local) = self.local_transform(root) {
stack.push((root, local));
}
}
while let Some((entity, world)) = stack.pop() {
out.insert(entity, world);
if let Some(children) = self.children.get(&entity) {
for &child in children {
if let Some(local) = self.local_transform(child) {
stack.push((child, world.mul_transform(&local)));
}
}
}
}
out
}
// --- ECS access --------------------------------------------------------
/// Whether `entity` is live in this scene.
pub fn contains(&self, entity: Entity) -> bool {
self.world.contains(entity)
}
/// Number of live entities.
pub fn len(&self) -> usize {
self.world.len() as usize
}
/// Whether the scene has no entities.
pub fn is_empty(&self) -> bool {
self.world.len() == 0
}
/// An iterator over every live entity, in unspecified order.
pub fn entities(&self) -> impl Iterator<Item = Entity> + '_ {
self.world.iter().map(|e| e.entity())
}
/// Borrows a component of `entity`, e.g. `scene.get::<Transform>(e)`.
pub fn get<T: Component>(&self, entity: Entity) -> Option<hecs::Ref<'_, T>> {
self.world.get::<&T>(entity).ok()
}
/// Mutably borrows a component of `entity`.
///
/// Do not mutate hierarchy state through here — use the scene's own
/// methods so the parent/child bookkeeping stays consistent.
pub fn get_mut<T: Component>(&mut self, entity: Entity) -> Option<hecs::RefMut<'_, T>> {
self.world.get::<&mut T>(entity).ok()
}
/// The underlying [`hecs::World`], for read-only ECS queries.
pub fn world(&self) -> &World {
&self.world
}
/// The underlying [`hecs::World`], for attaching extra components.
///
/// Spawning or despawning directly through the world bypasses the scene's
/// hierarchy bookkeeping; use [`spawn`](Self::spawn) /
/// [`despawn`](Self::despawn) for lifecycle and reserve this for adding or
/// querying non-hierarchy components.
pub fn world_mut(&mut self) -> &mut World {
&mut self.world
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Vec3;
fn t(x: f32, y: f32, z: f32) -> Transform {
Transform::from_translation(Vec3::new(x, y, z))
}
fn approx(a: Vec3, b: Vec3) -> bool {
(a - b).length() <= 1e-5
}
#[test]
fn spawn_makes_roots() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
let b = scene.spawn("b", Transform::IDENTITY);
assert_eq!(scene.roots(), &[a, b]);
assert_eq!(scene.len(), 2);
assert_eq!(scene.parent(a), None);
}
#[test]
fn spawn_child_links_both_ways() {
let mut scene = Scene::new();
let parent = scene.spawn("parent", Transform::IDENTITY);
let child = scene.spawn_child(parent, "child", Transform::IDENTITY);
assert_eq!(scene.parent(child), Some(parent));
assert_eq!(scene.children(parent), &[child]);
assert_eq!(scene.roots(), &[parent]); // child is not a root
}
#[test]
fn world_transform_composes_down_the_chain() {
let mut scene = Scene::new();
let a = scene.spawn("a", t(1.0, 0.0, 0.0));
let b = scene.spawn_child(a, "b", t(0.0, 2.0, 0.0));
let c = scene.spawn_child(b, "c", t(0.0, 0.0, 3.0));
let w = scene.world_transform(c).unwrap();
assert!(approx(w.translation, Vec3::new(1.0, 2.0, 3.0)));
}
#[test]
fn bulk_world_transforms_match_single() {
let mut scene = Scene::new();
let a = scene.spawn("a", t(5.0, 0.0, 0.0));
let b = scene.spawn_child(a, "b", t(0.0, 1.0, 0.0));
let c = scene.spawn_child(a, "c", t(0.0, 0.0, 1.0));
let all = scene.world_transforms();
for e in [a, b, c] {
assert!(approx(
all[&e].translation,
scene.world_transform(e).unwrap().translation
));
}
assert!(approx(all[&b].translation, Vec3::new(5.0, 1.0, 0.0)));
assert!(approx(all[&c].translation, Vec3::new(5.0, 0.0, 1.0)));
}
#[test]
fn rotation_propagates_to_children() {
use crate::math::Quat;
use std::f32::consts::FRAC_PI_2;
let mut scene = Scene::new();
// Parent rotated 90° about Z, child offset +X by 1.
let parent = scene.spawn(
"p",
Transform::from_rotation(Quat::from_rotation_z(FRAC_PI_2)),
);
let child = scene.spawn_child(parent, "c", t(1.0, 0.0, 0.0));
let w = scene.world_transform(child).unwrap();
// The +X offset is rotated into +Y by the parent.
assert!(approx(w.translation, Vec3::new(0.0, 1.0, 0.0)));
}
#[test]
fn despawn_recursive_removes_subtree() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
let b = scene.spawn_child(a, "b", Transform::IDENTITY);
let c = scene.spawn_child(b, "c", Transform::IDENTITY);
assert!(scene.despawn(a, DespawnPolicy::Recursive));
assert!(!scene.contains(a));
assert!(!scene.contains(b));
assert!(!scene.contains(c));
assert!(scene.roots().is_empty());
assert_eq!(scene.len(), 0);
}
#[test]
fn despawn_detach_promotes_children_to_grandparent() {
let mut scene = Scene::new();
let root = scene.spawn("root", Transform::IDENTITY);
let mid = scene.spawn_child(root, "mid", Transform::IDENTITY);
let leaf = scene.spawn_child(mid, "leaf", Transform::IDENTITY);
assert!(scene.despawn(mid, DespawnPolicy::DetachChildren));
assert!(!scene.contains(mid));
assert!(scene.contains(leaf));
// leaf is now a child of root directly.
assert_eq!(scene.parent(leaf), Some(root));
assert_eq!(scene.children(root), &[leaf]);
}
#[test]
fn despawn_detach_root_promotes_children_to_roots() {
let mut scene = Scene::new();
let root = scene.spawn("root", Transform::IDENTITY);
let child = scene.spawn_child(root, "child", Transform::IDENTITY);
assert!(scene.despawn(root, DespawnPolicy::DetachChildren));
assert_eq!(scene.parent(child), None);
assert_eq!(scene.roots(), &[child]);
}
#[test]
fn reparent_updates_links() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
let b = scene.spawn("b", Transform::IDENTITY);
let c = scene.spawn_child(a, "c", Transform::IDENTITY);
scene.set_parent(c, Some(b)).unwrap();
assert_eq!(scene.parent(c), Some(b));
assert_eq!(scene.children(a), &[] as &[Entity]);
assert_eq!(scene.children(b), &[c]);
}
#[test]
fn reparent_to_root() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
let c = scene.spawn_child(a, "c", Transform::IDENTITY);
scene.set_parent(c, None).unwrap();
assert_eq!(scene.parent(c), None);
assert!(scene.roots().contains(&c));
assert_eq!(scene.children(a), &[] as &[Entity]);
}
#[test]
fn reparent_cycle_is_rejected() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
let b = scene.spawn_child(a, "b", Transform::IDENTITY);
// Making `a` a child of its own descendant `b` would form a cycle.
assert_eq!(scene.set_parent(a, Some(b)), Err(SceneError::WouldCycle));
// Self-parenting is also a cycle.
assert_eq!(scene.set_parent(a, Some(a)), Err(SceneError::WouldCycle));
// The hierarchy is unchanged.
assert_eq!(scene.parent(b), Some(a));
assert_eq!(scene.parent(a), None);
}
#[test]
fn reparent_missing_entity_errors() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
scene.despawn(a, DespawnPolicy::Recursive);
assert_eq!(scene.set_parent(a, None), Err(SceneError::NoSuchEntity));
}
#[test]
fn enable_and_rename() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
assert_eq!(scene.is_enabled(a), Some(true));
assert!(scene.set_enabled(a, false));
assert_eq!(scene.is_enabled(a), Some(false));
assert!(scene.set_name(a, "renamed"));
assert_eq!(scene.name(a).as_deref(), Some("renamed"));
}
#[test]
fn reorder_moves_within_and_between_parents() {
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
let b = scene.spawn("b", Transform::IDENTITY);
let c = scene.spawn("c", Transform::IDENTITY);
assert_eq!(scene.roots(), &[a, b, c]);
// Reorder within the root list: move c before a → [c, a, b].
scene.reorder(c, None, Some(a)).unwrap();
assert_eq!(scene.roots(), &[c, a, b]);
// Same-parent move that crosses its old slot (no off-by-one): move c to
// just before b → [a, c, b].
scene.reorder(c, None, Some(b)).unwrap();
assert_eq!(scene.roots(), &[a, c, b]);
// Reparent + position: put b under a, before none → appended child.
scene.reorder(b, Some(a), None).unwrap();
assert_eq!(scene.roots(), &[a, c]);
assert_eq!(scene.children(a), &[b]);
assert_eq!(scene.parent(b), Some(a));
// Insert before an existing child: c under a, before b → [c, b].
scene.reorder(c, Some(a), Some(b)).unwrap();
assert_eq!(scene.children(a), &[c, b]);
// Cycle rejected: a cannot become a child of its descendant b.
assert!(matches!(
scene.reorder(a, Some(b), None),
Err(SceneError::WouldCycle)
));
}
#[test]
fn spawn_auto_attaches_layers_default() {
// Every entity is inherently *on* some layer (the default if not
// overridden) — so Layer is a node-baked component the scene always
// provides, not something the user has to add. Pinned here.
use super::super::super::layer::Layer;
let mut scene = Scene::new();
let e = scene.spawn("e", Transform::IDENTITY);
{
let layers = scene.world().get::<&Layer>(e).expect("Layer attached");
assert_eq!(*layers, Layer::DEFAULT);
}
let child = scene.spawn_child(e, "child", Transform::IDENTITY);
let layers = scene
.world()
.get::<&Layer>(child)
.expect("child gets Layer too");
assert_eq!(*layers, Layer::DEFAULT);
}
#[test]
fn is_component_disabled_reads_the_disabled_set() {
use super::super::DisabledComponents;
let mut scene = Scene::new();
let e = scene.spawn("e", Transform::IDENTITY);
// No DisabledComponents attached → nothing is disabled.
assert!(!scene.is_component_disabled(e, "MeshRenderer"));
let mut d = DisabledComponents::new();
d.set_disabled("MeshRenderer", true);
scene.world_mut().insert_one(e, d).unwrap();
assert!(scene.is_component_disabled(e, "MeshRenderer"));
assert!(!scene.is_component_disabled(e, "RigidBody"));
}
#[test]
fn effective_enabled_cascades_from_ancestors() {
let mut scene = Scene::new();
let player = scene.spawn("player", Transform::IDENTITY);
let camera = scene.spawn_child(player, "camera", Transform::IDENTITY);
let mesh = scene.spawn_child(camera, "mesh", Transform::IDENTITY);
// All enabled by default → effectively enabled.
assert_eq!(scene.is_effectively_enabled(mesh), Some(true));
// Disabling the root disables the whole subtree's effective state,
// even though each descendant's own flag is still true.
scene.set_enabled(player, false);
assert_eq!(scene.is_enabled(camera), Some(true)); // own flag unchanged
assert_eq!(scene.is_effectively_enabled(camera), Some(false));
assert_eq!(scene.is_effectively_enabled(mesh), Some(false));
// Re-enable the root; disable a middle node → only it + below are off.
scene.set_enabled(player, true);
scene.set_enabled(camera, false);
assert_eq!(scene.is_effectively_enabled(player), Some(true));
assert_eq!(scene.is_effectively_enabled(camera), Some(false));
assert_eq!(scene.is_effectively_enabled(mesh), Some(false));
// A dead entity has no effective state.
let ghost = scene.spawn("ghost", Transform::IDENTITY);
scene.despawn(ghost, DespawnPolicy::Recursive);
assert_eq!(scene.is_effectively_enabled(ghost), None);
}
#[test]
fn extra_components_via_world() {
// The scene is a real ECS: extra components can ride along on entities.
let mut scene = Scene::new();
let a = scene.spawn("a", Transform::IDENTITY);
scene.world_mut().insert_one(a, 42u32).unwrap();
assert_eq!(*scene.get::<u32>(a).unwrap(), 42);
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Scene graph and entity management.
//!
//! Stage 3 builds the world model every later system plugs into. Entities are
//! [`hecs`] handles living in a [`Scene`], which adds a parent/child
//! [`Transform`](crate::math::Transform) hierarchy on top of the bare ECS:
//!
//! - [`Scene`] — owns the entities and the hierarchy; spawn, despawn,
//! reparent, query, and resolve world-space transforms
//! - [`Node`] — per-entity metadata (`name`, `enabled`)
//! - [`DespawnPolicy`] — whether despawning takes the subtree with it or
//! detaches the children
//!
//! Local transforms are authored per entity; the scene resolves them against
//! the hierarchy on demand ([`Scene::world_transform`],
//! [`Scene::world_transforms`]). The node-baked hierarchy serializes to RON via
//! [`Scene::to_ron`] / [`Scene::from_ron`]; a registry-aware
//! [`SceneSnapshot`] (via [`Scene::snapshot`]) additionally captures every
//! reflected component, for play-mode restore and full scene files.
//!
//! `hecs` is re-exported as [`oxide_engine::hecs`](crate::hecs) so consumers
//! share one copy of [`Entity`](hecs::Entity) and the query API.
mod disabled;
mod graph;
mod node;
mod serialize;
mod snapshot;
pub use disabled::DisabledComponents;
pub use graph::{DespawnPolicy, Scene};
pub use node::Node;
pub use snapshot::SceneSnapshot;
// The handle type is part of the public scene API; re-export it here so callers
// can name it without reaching into the `hecs` re-export.
pub use hecs::Entity;
/// Errors produced by scene operations.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SceneError {
/// An operation referenced an entity that is not live in this scene.
#[error("entity does not exist in this scene")]
NoSuchEntity,
/// A reparent would have made an entity its own ancestor.
#[error("cannot parent an entity to itself or one of its descendants")]
WouldCycle,
/// Encoding the scene to RON failed.
#[error("scene serialization failed: {0}")]
Serialize(String),
/// Decoding the scene from RON failed, or the data was inconsistent.
#[error("scene deserialization failed: {0}")]
Deserialize(String),
}
+46
View File
@@ -0,0 +1,46 @@
//! The [`Node`] component: per-entity scene metadata.
use serde::{Deserialize, Serialize};
/// Metadata attached to every entity that participates in the scene graph.
///
/// A `Node` carries the human-facing identity of an entity (its `name`, shown
/// in the editor hierarchy) and an `enabled` flag. Disabling a node is a
/// declaration of intent that later systems honor — rendering, physics, and
/// audio skip disabled subtrees — but it does **not** affect transform
/// resolution, which is purely geometric. Stage 3 only stores and edits the
/// flag; the systems that act on it arrive in later stages.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, crate::reflect::Reflect)]
pub struct Node {
/// Display name. Need not be unique; entities are identified by their
/// [`Entity`](hecs::Entity) handle, not by name.
pub name: String,
/// Whether this node (and, by convention, its subtree) is active.
pub enabled: bool,
}
impl Node {
/// Creates an enabled node with the given name.
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
enabled: true,
}
}
}
impl Default for Node {
/// An enabled, unnamed node.
fn default() -> Self {
Self {
name: String::new(),
enabled: true,
}
}
}
impl<T: Into<String>> From<T> for Node {
fn from(name: T) -> Self {
Self::new(name)
}
}
+242
View File
@@ -0,0 +1,242 @@
//! RON serialization for [`Scene`].
//!
//! `hecs::Entity` handles are runtime values that are not stable across a
//! save/load, so the scene is flattened to a list of records with array
//! indices standing in for entity references. The list is built in a
//! deterministic pre-order walk of the hierarchy, so a serialize → deserialize
//! → serialize cycle is byte-for-byte stable.
use std::collections::HashMap;
use hecs::Entity;
use serde::{Deserialize, Serialize};
use super::{Node, Scene, SceneError};
use crate::math::Transform;
/// One entity in the flattened scene. `children` holds indices into the
/// surrounding [`SceneData::nodes`] list.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct NodeRecord {
name: String,
enabled: bool,
transform: Transform,
children: Vec<usize>,
}
/// The serializable form of a [`Scene`]: a flat node list plus the indices of
/// the root nodes. Parent links are implied by the `children` arrays.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SceneData {
nodes: Vec<NodeRecord>,
roots: Vec<usize>,
}
impl Scene {
/// Serializes the scene to a pretty-printed RON string.
pub fn to_ron(&self) -> Result<String, SceneError> {
let data = self.to_data();
ron::ser::to_string_pretty(&data, ron::ser::PrettyConfig::default())
.map_err(|e| SceneError::Serialize(e.to_string()))
}
/// Reconstructs a scene from a RON string produced by [`to_ron`](Self::to_ron).
pub fn from_ron(ron: &str) -> Result<Scene, SceneError> {
let data: SceneData =
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))?;
Scene::from_data(&data)
}
/// Flattens the hierarchy into index-based records via a deterministic
/// pre-order walk (roots in order, then each subtree depth-first).
fn to_data(&self) -> SceneData {
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(self.len());
let mut order: Vec<Entity> = Vec::with_capacity(self.len());
for &root in self.roots() {
self.assign_indices(root, &mut index, &mut order);
}
let nodes = order
.iter()
.map(|&entity| {
let node = self
.get::<Node>(entity)
.expect("entity in hierarchy must have a Node");
let transform = self
.local_transform(entity)
.expect("entity in hierarchy must have a Transform");
NodeRecord {
name: node.name.clone(),
enabled: node.enabled,
transform,
children: self.children(entity).iter().map(|c| index[c]).collect(),
}
})
.collect();
let roots = self.roots().iter().map(|r| index[r]).collect();
SceneData { nodes, roots }
}
/// Pre-order index assignment helper for [`to_data`](Self::to_data).
fn assign_indices(
&self,
entity: Entity,
index: &mut HashMap<Entity, usize>,
order: &mut Vec<Entity>,
) {
index.insert(entity, order.len());
order.push(entity);
for &child in self.children(entity) {
self.assign_indices(child, index, order);
}
}
/// Rebuilds a scene from flattened records, validating index references.
fn from_data(data: &SceneData) -> Result<Scene, SceneError> {
let mut scene = Scene::new();
let n = data.nodes.len();
// Spawn every entity first (as a root), so all indices resolve before
// wiring up parent/child links.
let entities: Vec<Entity> = data
.nodes
.iter()
.map(|rec| {
scene.spawn(
Node {
name: rec.name.clone(),
enabled: rec.enabled,
},
rec.transform,
)
})
.collect();
// Re-link: each record's children become children of that record's
// entity (and are removed from the root list).
for (i, rec) in data.nodes.iter().enumerate() {
for &child_idx in &rec.children {
let child = *entities
.get(child_idx)
.ok_or(SceneError::Deserialize(format!(
"child index {child_idx} out of range (have {n} nodes)"
)))?;
scene
.set_parent(child, Some(entities[i]))
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
}
}
// Validate the declared roots match the entities left parentless. The
// re-link above already produced the correct root set; we just confirm
// the file's `roots` list is consistent so corrupt input is rejected.
for &root_idx in &data.roots {
let entity = *entities
.get(root_idx)
.ok_or(SceneError::Deserialize(format!(
"root index {root_idx} out of range (have {n} nodes)"
)))?;
if scene.parent(entity).is_some() {
return Err(SceneError::Deserialize(format!(
"node {root_idx} is listed as a root but is also a child"
)));
}
}
Ok(scene)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::{Quat, Vec3};
use crate::scene::DespawnPolicy;
/// Builds a small, varied scene used by the round-trip tests.
fn sample() -> Scene {
let mut scene = Scene::new();
let root = scene.spawn(
Node {
name: "root".into(),
enabled: true,
},
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
);
let arm = scene.spawn_child(
root,
"arm",
Transform::from_rotation(Quat::from_rotation_y(0.5)),
);
scene.spawn_child(arm, "hand", Transform::from_scale(Vec3::splat(2.0)));
let mut disabled = Node::new("disabled");
disabled.enabled = false;
scene.spawn_child(root, disabled, Transform::IDENTITY);
// A second independent root, to exercise multi-root serialization.
scene.spawn("other-root", Transform::from_translation(Vec3::NEG_X));
scene
}
#[test]
fn round_trip_preserves_structure() {
let scene = sample();
let ron = scene.to_ron().unwrap();
let restored = Scene::from_ron(&ron).unwrap();
// Re-serializing the restored scene yields identical text: structure,
// names, flags, transforms, and ordering all survived.
assert_eq!(ron, restored.to_ron().unwrap());
assert_eq!(scene.len(), restored.len());
assert_eq!(scene.roots().len(), restored.roots().len());
}
#[test]
fn round_trip_preserves_world_transforms() {
let scene = sample();
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
// Compare resolved world transforms by name, since entity ids differ
// across the rebuild.
let by_name = |s: &Scene| -> Vec<(String, Vec3)> {
let worlds = s.world_transforms();
let mut v: Vec<_> = worlds
.iter()
.map(|(&e, t)| (s.name(e).unwrap(), t.translation))
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
};
let a = by_name(&scene);
let b = by_name(&restored);
assert_eq!(a.len(), b.len());
for ((na, ta), (nb, tb)) in a.iter().zip(b.iter()) {
assert_eq!(na, nb);
assert!((*ta - *tb).length() <= 1e-5, "{na}: {ta} vs {tb}");
}
}
#[test]
fn empty_scene_round_trips() {
let scene = Scene::new();
let restored = Scene::from_ron(&scene.to_ron().unwrap()).unwrap();
assert!(restored.is_empty());
}
#[test]
fn reordering_after_edits_still_round_trips() {
// Mutating the scene (despawn) must not break index bookkeeping.
let mut scene = sample();
let root = scene.roots()[0];
let kid = scene.children(root)[0];
scene.despawn(kid, DespawnPolicy::DetachChildren);
let ron = scene.to_ron().unwrap();
assert_eq!(ron, Scene::from_ron(&ron).unwrap().to_ron().unwrap());
}
#[test]
fn corrupt_child_index_is_rejected() {
let bad = r#"(nodes: [(name: "a", enabled: true, transform: (translation: (0,0,0), rotation: (0,0,0,1), scale: (1,1,1)), children: [5])], roots: [0])"#;
assert!(Scene::from_ron(bad).is_err());
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Registry-aware full-scene capture, for play mode (and future scene files).
//!
//! [`Scene::to_ron`](super::Scene::to_ron) records only the node-baked
//! `Node`/`Transform`/hierarchy. A [`SceneSnapshot`] additionally captures
//! **every reflected component** on each entity through the
//! [`TypeRegistry`], so a scene mutated while *playing* (physics moving bodies,
//! scripts spawning or editing entities) can be restored **bit-for-bit** when
//! play stops — avoiding Unity's classic "edited in play mode, lost it" footgun.
//!
//! Why a separate type from [`SceneData`](super::serialize): the plain RON form
//! is registry-free (it can round-trip without knowing any component types),
//! whereas a snapshot needs the registry to enumerate and serialize arbitrary
//! components. Keeping the two apart means the cheap path stays cheap.
//!
//! Fidelity is bounded by what is *registered*, plus the engine's intrinsic
//! components: every reflected type in the [`TypeRegistry`] is captured, as are
//! the built-in non-reflected components (`Node`/`Transform`, plus the inspector-
//! hidden [`Tags`] and [`DisabledComponents`]). A *module's* component that is
//! neither registered nor one of those is invisible to capture — modules that
//! want play-mode survival register their components, which they do anyway to be
//! editable. Within that set, capture → restore → capture is stable.
use std::collections::{BTreeMap, HashMap};
use hecs::Entity;
use serde::{Deserialize, Serialize};
use super::{DisabledComponents, Node, Scene, SceneError};
use crate::layer::Tags;
use crate::math::Transform;
use crate::reflect::TypeRegistry;
/// Components captured explicitly via [`Scene::spawn`] on restore, so they are
/// excluded from the per-node component map to avoid storing them twice.
/// (`Layer` is *not* here: it is auto-attached on spawn but carries authored
/// data, so it round-trips through the component map like any other component.)
const SPAWN_BAKED: [&str; 2] = ["Node", "Transform"];
/// One entity in a flattened [`SceneSnapshot`]. `children` holds indices into
/// the surrounding [`SceneSnapshot::nodes`] list (entity handles are not stable
/// across a capture/restore, so positions stand in for references).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SnapshotNode {
name: String,
enabled: bool,
transform: Transform,
children: Vec<usize>,
/// Every *other* registered component on the node, `type_name` → RON.
/// A `BTreeMap` so the serialized form is order-stable.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
components: BTreeMap<String, String>,
/// The entity's gameplay [`Tags`], if any. Captured directly (not via the
/// registry) because `Tags` is an engine-intrinsic component edited through
/// the inspector's dedicated Groups UI, not registered as a generic
/// reflected type — without this, Play→Stop would wipe group membership.
#[serde(default, skip_serializing_if = "Option::is_none")]
tags: Option<Tags>,
/// The entity's [`DisabledComponents`] set, if any. Captured directly for
/// the same reason as [`tags`](Self::tags): it's hidden engine metadata, not
/// a reflected authored component.
#[serde(default, skip_serializing_if = "Option::is_none")]
disabled: Option<DisabledComponents>,
}
/// A complete, restorable capture of a [`Scene`]: hierarchy plus every reflected
/// component on each node. See the [module docs](self).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SceneSnapshot {
nodes: Vec<SnapshotNode>,
roots: Vec<usize>,
}
impl SceneSnapshot {
/// Captures `scene` in full, serializing every component the `registry`
/// knows about on each entity. Built from a deterministic pre-order walk of
/// the hierarchy, so two captures of equal scenes compare equal.
pub fn capture(scene: &Scene, registry: &TypeRegistry) -> Self {
let mut index: HashMap<Entity, usize> = HashMap::with_capacity(scene.len());
let mut order: Vec<Entity> = Vec::with_capacity(scene.len());
for &root in scene.roots() {
assign_indices(scene, root, &mut index, &mut order);
}
let nodes = order
.iter()
.map(|&entity| {
// Pull the node-baked fields first, then drop the borrow before
// the reflection walk reads the same world.
let (name, enabled) = {
let node = scene
.get::<Node>(entity)
.expect("entity in hierarchy must have a Node");
(node.name.clone(), node.enabled)
};
let transform = scene
.local_transform(entity)
.expect("entity in hierarchy must have a Transform");
let mut components = BTreeMap::new();
for type_name in registry.components_on(scene.world(), entity) {
if SPAWN_BAKED.contains(&type_name) {
continue;
}
if let Ok(ron) = registry.get_ron(scene.world(), entity, type_name) {
components.insert(type_name.to_string(), ron);
}
}
SnapshotNode {
name,
enabled,
transform,
children: scene.children(entity).iter().map(|c| index[c]).collect(),
components,
tags: scene.get::<Tags>(entity).map(|t| (*t).clone()),
disabled: scene
.get::<DisabledComponents>(entity)
.map(|d| (*d).clone()),
}
})
.collect();
let roots = scene.roots().iter().map(|r| index[r]).collect();
SceneSnapshot { nodes, roots }
}
/// Rebuilds a fresh [`Scene`] from this snapshot. Spawns every node (which
/// auto-attaches the node-baked `Node`/`Transform`/`Layer`), re-links the
/// hierarchy, then applies each captured component over the defaults.
///
/// Entity handles in the new scene differ from the captured ones — callers
/// holding an [`Entity`] (e.g. an editor selection) must drop or re-resolve
/// it after a restore.
///
/// # Errors
/// [`Deserialize`](SceneError::Deserialize) if an index reference is out of
/// range or a component's stored RON no longer parses for its type.
pub fn restore(&self, registry: &TypeRegistry) -> Result<Scene, SceneError> {
let mut scene = Scene::new();
let n = self.nodes.len();
// Spawn every entity first (as a root) so all indices resolve before
// wiring parent/child links.
let entities: Vec<Entity> = self
.nodes
.iter()
.map(|rec| {
scene.spawn(
Node {
name: rec.name.clone(),
enabled: rec.enabled,
},
rec.transform,
)
})
.collect();
// Re-link the hierarchy.
for (i, rec) in self.nodes.iter().enumerate() {
for &child_idx in &rec.children {
let child = *entities.get(child_idx).ok_or_else(|| {
SceneError::Deserialize(format!(
"child index {child_idx} out of range (have {n} nodes)"
))
})?;
scene
.set_parent(child, Some(entities[i]))
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
}
}
// Apply captured components over the spawn defaults.
for (i, rec) in self.nodes.iter().enumerate() {
for (type_name, ron) in &rec.components {
registry
.set_ron(scene.world_mut(), entities[i], type_name, ron)
.map_err(|e| SceneError::Deserialize(e.to_string()))?;
}
// Reinstate the engine-intrinsic, non-reflected components.
if let Some(tags) = &rec.tags {
let _ = scene.world_mut().insert_one(entities[i], tags.clone());
}
if let Some(disabled) = &rec.disabled {
let _ = scene.world_mut().insert_one(entities[i], disabled.clone());
}
}
Ok(scene)
}
/// Serializes the snapshot to a pretty-printed RON string.
///
/// # Errors
/// [`Serialize`](SceneError::Serialize) if encoding fails.
pub fn to_ron(&self) -> Result<String, SceneError> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| SceneError::Serialize(e.to_string()))
}
/// Reconstructs a snapshot from a string produced by [`to_ron`](Self::to_ron).
///
/// # Errors
/// [`Deserialize`](SceneError::Deserialize) if the text is not valid.
pub fn from_ron(ron: &str) -> Result<Self, SceneError> {
ron::from_str(ron).map_err(|e| SceneError::Deserialize(e.to_string()))
}
}
/// Pre-order index assignment: mirrors the plain-RON walk so snapshot and
/// `to_ron` order entities identically.
fn assign_indices(
scene: &Scene,
entity: Entity,
index: &mut HashMap<Entity, usize>,
order: &mut Vec<Entity>,
) {
index.insert(entity, order.len());
order.push(entity);
for &child in scene.children(entity) {
assign_indices(scene, child, index, order);
}
}
impl Scene {
/// Captures this scene in full (hierarchy + every reflected component) into
/// a restorable [`SceneSnapshot`]. See that type for why it differs from
/// [`to_ron`](Self::to_ron).
pub fn snapshot(&self, registry: &TypeRegistry) -> SceneSnapshot {
SceneSnapshot::capture(self, registry)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Vec3;
use crate::render::{MeshRenderer, PrimitiveShape};
/// A registry seeded like the editor's: node-baked types plus a couple of
/// modular components, so snapshots exercise the component map.
fn registry() -> TypeRegistry {
let mut r = TypeRegistry::new();
r.register_reflected::<Transform>("Transform");
r.register_reflected::<Node>("Node");
r.register_reflected::<crate::layer::Layer>("Layer");
r.register_reflected::<MeshRenderer>("MeshRenderer");
r
}
fn sample_scene() -> Scene {
let mut scene = Scene::new();
let parent = scene.spawn("parent", Transform::IDENTITY);
let child = scene.spawn(
"child",
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
);
scene.set_parent(child, Some(parent)).unwrap();
scene
.world_mut()
.insert_one(
child,
MeshRenderer {
shape: PrimitiveShape::Sphere,
..MeshRenderer::default()
},
)
.unwrap();
scene
}
#[test]
fn capture_restore_round_trips_components_and_hierarchy() {
let reg = registry();
let scene = sample_scene();
let snap = scene.snapshot(&reg);
let restored = snap.restore(&reg).unwrap();
// Hierarchy (plain RON) matches.
assert_eq!(scene.to_ron().unwrap(), restored.to_ron().unwrap());
// And the full snapshot (incl. components) matches.
assert_eq!(snap, restored.snapshot(&reg));
}
#[test]
fn restore_reinstates_a_modular_component() {
let reg = registry();
let scene = sample_scene();
let snap = scene.snapshot(&reg);
let restored = snap.restore(&reg).unwrap();
// The child's MeshRenderer (Sphere) survived the round-trip.
let child = restored
.entities()
.find(|&e| {
restored
.get::<Node>(e)
.map(|n| n.name == "child")
.unwrap_or(false)
})
.unwrap();
let mesh = restored.get::<MeshRenderer>(child).unwrap();
assert_eq!(mesh.shape, PrimitiveShape::Sphere);
}
#[test]
fn stop_reverts_a_play_mode_mutation_bit_for_bit() {
// The play-mode contract: snapshot, mutate (as a tick would), restore,
// and the scene returns to its captured form.
let reg = registry();
let mut scene = sample_scene();
let snap = scene.snapshot(&reg);
// Mutate every transform, as a physics/script tick might.
let entities: Vec<_> = scene.entities().collect();
for e in entities {
let t = scene.local_transform(e).unwrap();
scene.set_local_transform(e, Transform::from_translation(t.translation + Vec3::X));
}
assert_ne!(snap, scene.snapshot(&reg));
let reverted = snap.restore(&reg).unwrap();
assert_eq!(snap, reverted.snapshot(&reg));
}
#[test]
fn captures_intrinsic_tags_and_disabled() {
use crate::layer::Tags;
use crate::scene::DisabledComponents;
let reg = registry();
let mut scene = sample_scene();
let parent = scene
.entities()
.find(|&e| {
scene
.get::<Node>(e)
.map(|n| n.name == "parent")
.unwrap_or(false)
})
.unwrap();
scene
.world_mut()
.insert_one(parent, Tags::single("Enemy"))
.unwrap();
let mut dc = DisabledComponents::new();
dc.set_disabled("MeshRenderer", true);
scene.world_mut().insert_one(parent, dc).unwrap();
let snap = scene.snapshot(&reg);
let restored = snap.restore(&reg).unwrap();
// Both engine-intrinsic, non-reflected components survive the round-trip
// even though neither is in the registry.
assert_eq!(snap, restored.snapshot(&reg));
let rparent = restored
.entities()
.find(|&e| {
restored
.get::<Node>(e)
.map(|n| n.name == "parent")
.unwrap_or(false)
})
.unwrap();
assert!(restored.get::<Tags>(rparent).unwrap().contains("Enemy"));
assert!(restored
.get::<DisabledComponents>(rparent)
.unwrap()
.is_disabled("MeshRenderer"));
}
#[test]
fn ron_round_trips() {
let reg = registry();
let snap = sample_scene().snapshot(&reg);
let ron = snap.to_ron().unwrap();
assert_eq!(snap, SceneSnapshot::from_ron(&ron).unwrap());
}
}
+273
View File
@@ -0,0 +1,273 @@
//! The settings / preferences framework.
//!
//! A unified, serialized configuration store shared by the engine, the editor,
//! and modules. Each contributor registers a typed **section** (a plain
//! `serde`-serializable struct) under a name; the framework persists every
//! section to RON and restores it, without any central code knowing the
//! sections' shapes. This is what lets:
//!
//! - **engine** preferences (render/quality defaults),
//! - **editor** preferences (theme, layout, shortcuts), and
//! - **per-module** settings (each module's own options)
//!
//! all live in one place, while a [`Project`](crate::project::Project) persists
//! the per-project subset (it stores section → RON blobs that line up exactly
//! with [`Settings::export`]/[`Settings::import`]).
//!
//! ```
//! use oxide_engine::settings::Settings;
//! use serde::{Serialize, Deserialize};
//!
//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
//! struct EditorPrefs { theme: String, grid: bool }
//!
//! let mut settings = Settings::new();
//! settings.register::<EditorPrefs>("editor");
//! settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
//!
//! // Persist every section to RON, and restore it later.
//! let saved = settings.export();
//! let mut restored = Settings::new();
//! restored.register::<EditorPrefs>("editor");
//! restored.import(&saved);
//! assert_eq!(restored.get::<EditorPrefs>("editor").unwrap().theme, "dark");
//! ```
use std::any::Any;
use std::collections::BTreeMap;
use serde::de::DeserializeOwned;
use serde::Serialize;
/// The monomorphized operations for one registered section, as plain function
/// pointers (the closures capture nothing).
struct SectionOps {
value: Box<dyn Any>,
to_ron: fn(&dyn Any) -> Option<String>,
from_ron: fn(&str) -> Option<Box<dyn Any>>,
default: fn() -> Box<dyn Any>,
}
/// A registry of typed, serializable settings sections keyed by name.
#[derive(Default)]
pub struct Settings {
sections: BTreeMap<&'static str, SectionOps>,
}
impl Settings {
/// An empty settings store.
pub fn new() -> Self {
Self::default()
}
/// Registers section type `T` under `name`, initialized to `T::default()`.
/// Re-registering the same name resets it to default.
pub fn register<T>(&mut self, name: &'static str)
where
T: Serialize + DeserializeOwned + Default + 'static,
{
self.sections.insert(
name,
SectionOps {
value: Box::new(T::default()),
to_ron: |any| any.downcast_ref::<T>().and_then(|v| ron::to_string(v).ok()),
from_ron: |text| {
ron::from_str::<T>(text)
.ok()
.map(|v| Box::new(v) as Box<dyn Any>)
},
default: || Box::new(T::default()) as Box<dyn Any>,
},
);
}
/// Whether a section is registered under `name`.
pub fn is_registered(&self, name: &str) -> bool {
self.sections.contains_key(name)
}
/// The registered section names, sorted.
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
self.sections.keys().copied()
}
/// Borrows section `name` as `T`, or `None` if absent or the type mismatches.
pub fn get<T: 'static>(&self, name: &str) -> Option<&T> {
self.sections.get(name)?.value.downcast_ref::<T>()
}
/// Mutably borrows section `name` as `T`.
pub fn get_mut<T: 'static>(&mut self, name: &str) -> Option<&mut T> {
self.sections.get_mut(name)?.value.downcast_mut::<T>()
}
/// Replaces the value of section `name`. Returns whether it was registered
/// (with a matching type).
pub fn set<T: 'static>(&mut self, name: &str, value: T) -> bool {
match self.sections.get_mut(name) {
// Only overwrite if the registered type matches.
Some(section) if section.value.is::<T>() => {
section.value = Box::new(value);
true
}
_ => false,
}
}
/// Resets section `name` to its default. Returns whether it was registered.
pub fn reset(&mut self, name: &str) -> bool {
match self.sections.get_mut(name) {
Some(section) => {
section.value = (section.default)();
true
}
None => false,
}
}
/// Serializes section `name` to RON, or `None` if it is not registered.
pub fn section_ron(&self, name: &str) -> Option<String> {
let section = self.sections.get(name)?;
(section.to_ron)(section.value.as_ref())
}
/// Loads section `name` from a RON blob, replacing its value. Returns `false`
/// if the section is not registered or the text fails to parse.
pub fn load_section(&mut self, name: &str, ron: &str) -> bool {
match self.sections.get_mut(name) {
Some(section) => match (section.from_ron)(ron) {
Some(value) => {
section.value = value;
true
}
None => false,
},
None => false,
}
}
/// Serializes every section to a `name → RON` map (the format a
/// [`Project`](crate::project::Project) stores).
pub fn export(&self) -> BTreeMap<String, String> {
self.sections
.iter()
.filter_map(|(name, section)| {
(section.to_ron)(section.value.as_ref()).map(|ron| (name.to_string(), ron))
})
.collect()
}
/// Loads every matching, registered section from a `name → RON` map.
/// Unknown sections are ignored (a module may be disabled); malformed
/// sections are skipped, leaving their current value.
pub fn import(&mut self, map: &BTreeMap<String, String>) {
for (name, ron) in map {
self.load_section(name, ron);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
struct EditorPrefs {
theme: String,
grid: bool,
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Render {
shadows: bool,
msaa: u32,
}
impl Default for Render {
fn default() -> Self {
Self {
shadows: true,
msaa: 4,
}
}
}
fn settings() -> Settings {
let mut s = Settings::new();
s.register::<EditorPrefs>("editor");
s.register::<Render>("render");
s
}
#[test]
fn defaults_and_typed_access() {
let mut s = settings();
assert_eq!(s.names().collect::<Vec<_>>(), vec!["editor", "render"]);
assert!(s.get::<Render>("render").unwrap().shadows);
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "dark");
// Wrong type → None.
assert!(s.get::<Render>("editor").is_none());
}
#[test]
fn set_and_reset() {
let mut s = settings();
assert!(s.set(
"render",
Render {
shadows: false,
msaa: 8
}
));
assert_eq!(s.get::<Render>("render").unwrap().msaa, 8);
// Setting an unregistered section fails.
assert!(!s.set("missing", 5u32));
// Reset returns to default.
assert!(s.reset("render"));
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
}
#[test]
fn export_import_round_trips() {
let mut s = settings();
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "light".into();
s.get_mut::<EditorPrefs>("editor").unwrap().grid = true;
s.set(
"render",
Render {
shadows: false,
msaa: 2,
},
);
let saved = s.export();
// A fresh store with the same sections restores the saved values.
let mut restored = settings();
restored.import(&saved);
assert_eq!(
restored.get::<EditorPrefs>("editor").unwrap(),
&EditorPrefs {
theme: "light".into(),
grid: true
}
);
assert_eq!(restored.get::<Render>("render").unwrap().msaa, 2);
}
#[test]
fn import_ignores_unknown_and_malformed() {
let mut s = settings();
let mut map = BTreeMap::new();
map.insert("editor".to_string(), "(theme:\"x\",grid:true)".to_string());
map.insert("disabled_module".to_string(), "(whatever:1)".to_string());
map.insert("render".to_string(), "not valid ron".to_string());
s.import(&map);
// Known + valid applied.
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "x");
// Malformed left the section at its default (unchanged).
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
// Unknown silently ignored.
assert!(!s.is_registered("disabled_module"));
}
}
+763
View File
@@ -0,0 +1,763 @@
//! Layout algorithm — turns a [`Widget`] tree into resolved screen rects.
//!
//! [`layout`] is a single recursive top-down pass that mixes a one-shot
//! intrinsic-size measurement (for `FitContent` and `Grow` accounting) with
//! the actual placement. The resulting [`LayoutTree`] is a flat `Vec` of
//! [`LayoutNode`]s; each node records its own `rect`, `content_rect`
//! (padding-inset), and the indices of its direct children. The layout
//! function itself has no GPU, no input, no allocation outside the result —
//! every test in this stage runs headlessly.
//!
//! # Slot vs rect, and why anchor children skip resizing
//!
//! The recursion uses two entry points:
//!
//! - [`arrange_in_slot`] is for stack / grid children and the root: the slot
//! is the **outer space** the widget can occupy; the algorithm applies the
//! widget's margin, sizing, and alignment to derive its rect.
//! - [`arrange_in_rect`] is for anchor children: the rect is *already* what
//! the anchor decided; the widget's margin / sizing / alignment are skipped
//! so the anchor is authoritative. Padding still applies (it's an inside-
//! the-rect concern). This matches the Unity/Godot convention that "anchor
//! determines rect" — sizing knobs would let the child silently disagree
//! with the anchor it was placed by.
//!
//! # DPI scale factor
//!
//! Every linear input (sizing, padding, margin, gaps, anchor offsets) is in
//! logical pixels and multiplied by [`layout`]'s `scale` argument at resolve
//! time. The widget tree is DPI-independent; the layout call is where the
//! display's scale factor enters.
use glam::Vec2;
use serde::{Deserialize, Serialize};
use crate::math::Rect;
use super::style::{Align, Insets, LayoutStyle, Sizing};
use super::widget::{AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind};
/// One node in a resolved [`LayoutTree`] — the widget's id and its on-screen
/// rectangles.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutNode {
/// Mirror of [`Widget::id`].
pub id: WidgetId,
/// The outer rectangle the widget occupies, after margin / sizing /
/// alignment.
pub rect: Rect,
/// `rect` minus the widget's padding — the area children are arranged
/// inside.
pub content_rect: Rect,
/// Indices into [`LayoutTree::nodes`] of the direct children, in the same
/// order as on the input widget.
pub children: Vec<u32>,
}
/// Result of laying out a widget tree — a flat array of [`LayoutNode`]s with
/// the root at index 0.
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutTree {
nodes: Vec<LayoutNode>,
}
impl LayoutTree {
/// All nodes, root first, in the pre-order produced by [`layout`].
pub fn nodes(&self) -> &[LayoutNode] {
&self.nodes
}
/// The root node (always present after a successful layout).
pub fn root(&self) -> Option<&LayoutNode> {
self.nodes.first()
}
/// Look up the first node with the given non-empty id.
///
/// Returns `None` if `id` is empty or no node matches. Linear scan — fine
/// for the dozens-of-widgets trees Stage 8 currently targets; a hash map
/// can be added if a profile says it's hot.
pub fn find(&self, id: &WidgetId) -> Option<&LayoutNode> {
if id.is_empty() {
return None;
}
self.nodes.iter().find(|n| n.id == *id)
}
/// The direct children of the node at `index`.
pub fn children_of(&self, index: usize) -> impl Iterator<Item = &LayoutNode> + '_ {
self.nodes[index]
.children
.iter()
.map(move |i| &self.nodes[*i as usize])
}
}
/// Lay out `root` inside `viewport` at the given DPI `scale`, producing a
/// [`LayoutTree`] with one entry per widget in pre-order.
pub fn layout(root: &Widget, viewport: Rect, scale: f32) -> LayoutTree {
let mut nodes = Vec::with_capacity(root.node_count());
arrange_in_slot(root, viewport, scale, &mut nodes);
LayoutTree { nodes }
}
// ---------- internal: recursive arrangement ----------
fn arrange_in_slot(widget: &Widget, slot: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
let margin = widget.style.margin.scaled(scale);
let outer = shrink(slot, margin);
let outer_size = outer.size();
let intrinsic = measure(widget, outer_size, scale);
let resolved_w = resolve_axis(widget.style.width, outer_size.x, intrinsic.x, scale);
let resolved_h = resolve_axis(widget.style.height, outer_size.y, intrinsic.y, scale);
let resolved = Vec2::new(resolved_w, resolved_h);
let extra = (outer_size - resolved).max(Vec2::ZERO);
let offset = Vec2::new(
align_offset(widget.style.align_horizontal, extra.x),
align_offset(widget.style.align_vertical, extra.y),
);
let rect = Rect::from_min_size(outer.min + offset, resolved);
arrange_in_rect(widget, rect, scale, out)
}
fn arrange_in_rect(widget: &Widget, rect: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
let padding = widget.style.padding.scaled(scale);
let content_rect = shrink(rect, padding);
let my_idx = out.len() as u32;
out.push(LayoutNode {
id: widget.id.clone(),
rect,
content_rect,
children: Vec::new(),
});
match &widget.kind {
WidgetKind::Leaf { .. } => {}
WidgetKind::Stack(stack) => arrange_stack(my_idx, stack, content_rect, scale, out),
WidgetKind::Grid(grid) => arrange_grid(my_idx, grid, content_rect, scale, out),
WidgetKind::Anchor(group) => arrange_anchor(my_idx, group, content_rect, scale, out),
}
my_idx
}
fn arrange_stack(
parent_idx: u32,
stack: &Stack,
content: Rect,
scale: f32,
out: &mut Vec<LayoutNode>,
) {
let n = stack.children.len();
if n == 0 {
return;
}
let gap = stack.gap * scale;
let total_gap = gap * n.saturating_sub(1) as f32;
let content_main = main_extent(stack.direction, content.size());
let content_cross = cross_extent(stack.direction, content.size());
// Pass 1: compute each child's main-axis size (fixed/fit) and tally
// grow weights.
let mut main_sizes: Vec<f32> = Vec::with_capacity(n);
let mut grow_weights: Vec<Option<f32>> = Vec::with_capacity(n);
let mut fixed_main_total = 0.0_f32;
let mut total_grow = 0.0_f32;
for child in &stack.children {
let margin = child.style.margin.scaled(scale);
let margin_main = main_extent(
stack.direction,
Vec2::new(margin.horizontal(), margin.vertical()),
);
let main_sizing = match stack.direction {
StackDirection::Row => child.style.width,
StackDirection::Column => child.style.height,
};
let (inner_main, weight) = match main_sizing {
Sizing::Fixed(v) => (v * scale, None),
Sizing::FitContent => {
let m = measure(child, content.size(), scale);
(main_extent(stack.direction, m), None)
}
Sizing::Grow(w) => (0.0, Some(w.max(0.0))),
};
if let Some(w) = weight {
total_grow += w;
}
grow_weights.push(weight);
main_sizes.push(inner_main + margin_main);
fixed_main_total += inner_main + margin_main;
}
let leftover = (content_main - fixed_main_total - total_gap).max(0.0);
if total_grow > 0.0 {
for (i, w) in grow_weights.iter().enumerate() {
if let Some(w) = w {
main_sizes[i] += leftover * (*w / total_grow);
}
}
}
// After distributing Grow, any remaining slack is positioned via the
// stack's `main_align`. (If any child grew, slack is zero.)
let used_main: f32 = main_sizes.iter().sum::<f32>() + total_gap;
let extra = (content_main - used_main).max(0.0);
let start_offset = align_offset(stack.main_align, extra);
// Pass 2: place each child in its slot.
let mut cursor = start_offset;
let mut child_indices = Vec::with_capacity(n);
for (i, child) in stack.children.iter().enumerate() {
let slot_main = main_sizes[i];
let slot = make_slot(stack.direction, content, cursor, slot_main, content_cross);
cursor += slot_main + gap;
child_indices.push(arrange_in_slot(child, slot, scale, out));
}
out[parent_idx as usize].children = child_indices;
}
fn arrange_grid(
parent_idx: u32,
grid: &Grid,
content: Rect,
scale: f32,
out: &mut Vec<LayoutNode>,
) {
if grid.cols == 0 || grid.rows == 0 || grid.children.is_empty() {
return;
}
let gap = grid.gap * scale;
let total_gap_x = gap.x * grid.cols.saturating_sub(1) as f32;
let total_gap_y = gap.y * grid.rows.saturating_sub(1) as f32;
let cell_w = ((content.width() - total_gap_x) / grid.cols as f32).max(0.0);
let cell_h = ((content.height() - total_gap_y) / grid.rows as f32).max(0.0);
let cells = grid.cols * grid.rows;
let mut child_indices = Vec::with_capacity(grid.children.len().min(cells as usize));
for (i, child) in grid.children.iter().enumerate() {
if i as u32 >= cells {
break;
}
let row = i as u32 / grid.cols;
let col = i as u32 % grid.cols;
let cell_origin =
content.min + Vec2::new(col as f32 * (cell_w + gap.x), row as f32 * (cell_h + gap.y));
let slot = Rect::from_min_size(cell_origin, Vec2::new(cell_w, cell_h));
child_indices.push(arrange_in_slot(child, slot, scale, out));
}
out[parent_idx as usize].children = child_indices;
}
fn arrange_anchor(
parent_idx: u32,
group: &AnchorGroup,
content: Rect,
scale: f32,
out: &mut Vec<LayoutNode>,
) {
let size = content.size();
let mut child_indices = Vec::with_capacity(group.children.len());
for child in &group.children {
let a = child.style.anchor;
let min = content.min + size * a.min + a.offset_min * scale;
let max = content.min + size * a.max + a.offset_max * scale;
let target = Rect::new(min, max);
child_indices.push(arrange_in_rect(child, target, scale, out));
}
out[parent_idx as usize].children = child_indices;
}
// ---------- internal: measurement ----------
fn measure(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
match &widget.kind {
WidgetKind::Leaf { intrinsic } => *intrinsic * scale,
WidgetKind::Stack(stack) => measure_stack(&widget.style, stack, available, scale),
WidgetKind::Grid(grid) => measure_grid(&widget.style, grid, available, scale),
// Anchor parents derive their children's rects from the parent's size,
// so they can't propose an intrinsic "fit" size; FitContent on an
// anchor parent collapses to zero.
WidgetKind::Anchor(_) => Vec2::ZERO,
}
}
/// Outer footprint of a child (the slot it would consume in its parent),
/// including its own margin.
fn measure_outer(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
let intrinsic = measure(widget, available, scale);
let w = match widget.style.width {
Sizing::Fixed(v) => v * scale,
Sizing::FitContent => intrinsic.x,
Sizing::Grow(_) => 0.0,
};
let h = match widget.style.height {
Sizing::Fixed(v) => v * scale,
Sizing::FitContent => intrinsic.y,
Sizing::Grow(_) => 0.0,
};
let m = widget.style.margin.scaled(scale);
Vec2::new(w + m.horizontal(), h + m.vertical())
}
fn measure_stack(parent_style: &LayoutStyle, stack: &Stack, available: Vec2, scale: f32) -> Vec2 {
let mut main = 0.0_f32;
let mut cross = 0.0_f32;
let n = stack.children.len();
for child in &stack.children {
let s = measure_outer(child, available, scale);
main += main_extent(stack.direction, s);
cross = cross.max(cross_extent(stack.direction, s));
}
if n > 1 {
main += stack.gap * scale * (n - 1) as f32;
}
let p = parent_style.padding.scaled(scale);
match stack.direction {
StackDirection::Row => Vec2::new(main + p.horizontal(), cross + p.vertical()),
StackDirection::Column => Vec2::new(cross + p.horizontal(), main + p.vertical()),
}
}
fn measure_grid(parent_style: &LayoutStyle, grid: &Grid, available: Vec2, scale: f32) -> Vec2 {
if grid.cols == 0 || grid.rows == 0 {
return Vec2::ZERO;
}
let mut cell_w = 0.0_f32;
let mut cell_h = 0.0_f32;
for child in &grid.children {
let s = measure_outer(child, available, scale);
cell_w = cell_w.max(s.x);
cell_h = cell_h.max(s.y);
}
let gap = grid.gap * scale;
let total = Vec2::new(
cell_w * grid.cols as f32 + gap.x * grid.cols.saturating_sub(1) as f32,
cell_h * grid.rows as f32 + gap.y * grid.rows.saturating_sub(1) as f32,
);
let p = parent_style.padding.scaled(scale);
Vec2::new(total.x + p.horizontal(), total.y + p.vertical())
}
// ---------- internal: small helpers ----------
fn shrink(r: Rect, i: Insets) -> Rect {
let min = r.min + Vec2::new(i.left, i.top);
let max = r.max - Vec2::new(i.right, i.bottom);
Rect::new(min, max)
}
fn align_offset(align: Align, extra: f32) -> f32 {
match align {
Align::Start => 0.0,
Align::Center => extra * 0.5,
Align::End => extra,
}
}
fn resolve_axis(sizing: Sizing, available: f32, intrinsic: f32, scale: f32) -> f32 {
match sizing {
Sizing::Fixed(v) => (v * scale).min(available),
Sizing::Grow(_) => available,
Sizing::FitContent => intrinsic.min(available),
}
}
fn main_extent(dir: StackDirection, v: Vec2) -> f32 {
match dir {
StackDirection::Row => v.x,
StackDirection::Column => v.y,
}
}
fn cross_extent(dir: StackDirection, v: Vec2) -> f32 {
match dir {
StackDirection::Row => v.y,
StackDirection::Column => v.x,
}
}
fn make_slot(dir: StackDirection, content: Rect, cursor: f32, main: f32, cross: f32) -> Rect {
match dir {
StackDirection::Row => {
Rect::from_min_size(content.min + Vec2::new(cursor, 0.0), Vec2::new(main, cross))
}
StackDirection::Column => {
Rect::from_min_size(content.min + Vec2::new(0.0, cursor), Vec2::new(cross, main))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::style::Anchor;
fn vp(w: f32, h: f32) -> Rect {
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
}
/// `Grow(1.0)` on both axes — the common "fill the parent" style for
/// container tests where intrinsic sizing would collapse the root.
fn grow_both() -> LayoutStyle {
LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
}
}
#[test]
fn single_leaf_takes_intrinsic_size_at_origin() {
let w = Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a");
let tree = layout(&w, vp(800.0, 600.0), 1.0);
let n = tree.find(&"a".into()).unwrap();
assert_eq!(
n.rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
);
assert_eq!(n.content_rect, n.rect);
assert_eq!(tree.nodes().len(), 1);
}
#[test]
fn dpi_scale_doubles_sizes() {
let w = Widget::leaf(Vec2::new(40.0, 20.0));
let tree = layout(&w, vp(800.0, 600.0), 2.0);
let n = tree.root().unwrap();
assert_eq!(n.rect.size(), Vec2::new(80.0, 40.0));
}
#[test]
fn row_stack_places_fixed_children_with_gap() {
let row = Widget::row()
.with_id("row")
.with_gap(4.0)
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("b"))
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
let b = tree.find(&"b".into()).unwrap().rect;
let c = tree.find(&"c".into()).unwrap().rect;
assert_eq!(a, Rect::from_min_size(Vec2::ZERO, Vec2::new(30.0, 20.0)));
assert_eq!(
b,
Rect::from_min_size(Vec2::new(34.0, 0.0), Vec2::new(50.0, 20.0))
);
assert_eq!(
c,
Rect::from_min_size(Vec2::new(88.0, 0.0), Vec2::new(10.0, 20.0))
);
}
#[test]
fn row_stack_grow_fills_leftover_space() {
// 200 wide; A=30 fixed, B=Grow, C=10 fixed → B gets 160 wide.
let row =
Widget::row()
.with_id("row")
.with_style(grow_both())
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(0.0, 20.0)).with_id("b").with_style(
LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Fixed(20.0),
..Default::default()
},
))
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let b = tree.find(&"b".into()).unwrap().rect;
assert_eq!(b.min.x, 30.0);
assert_eq!(b.width(), 160.0);
assert_eq!(tree.find(&"c".into()).unwrap().rect.min.x, 190.0);
}
#[test]
fn row_stack_grow_weights_split_proportionally() {
// 300 wide root; A=Grow(1), B=Grow(2) → A gets 100, B gets 200.
let row = Widget::row()
.with_style(grow_both())
.with_child(Widget::default().with_id("a").with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
}))
.with_child(Widget::default().with_id("b").with_style(LayoutStyle {
width: Sizing::Grow(2.0),
height: Sizing::Grow(1.0),
..Default::default()
}));
let tree = layout(&row, vp(300.0, 30.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
let b = tree.find(&"b".into()).unwrap().rect;
assert_eq!(a.width(), 100.0);
assert_eq!(b.width(), 200.0);
assert_eq!(b.min.x, 100.0);
// Cross axis Grow fills full height.
assert_eq!(a.height(), 30.0);
assert_eq!(b.height(), 30.0);
}
#[test]
fn row_stack_main_align_center_splits_extra() {
// Two 30-wide children with gap 0 → main extent 60; viewport 200 →
// 140 extra, centered → 70 each side.
let row = Widget::row()
.with_main_align(Align::Center)
.with_style(grow_both())
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("b"));
let tree = layout(&row, vp(200.0, 20.0), 1.0);
assert_eq!(tree.find(&"a".into()).unwrap().rect.min.x, 70.0);
assert_eq!(tree.find(&"b".into()).unwrap().rect.min.x, 100.0);
}
#[test]
fn row_stack_cross_align_end_docks_to_bottom() {
// Child is 30x10 in a 100-wide row with 40 tall → align End → top=30.
let row = Widget::row().with_style(grow_both()).with_child(
Widget::leaf(Vec2::new(30.0, 10.0))
.with_id("a")
.with_style(LayoutStyle {
align_vertical: Align::End,
..Default::default()
}),
);
let tree = layout(&row, vp(100.0, 40.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
assert_eq!(a.min.y, 30.0);
assert_eq!(a.max.y, 40.0);
}
#[test]
fn column_stack_flows_top_to_bottom() {
let col = Widget::column()
.with_gap(2.0)
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(20.0, 30.0)).with_id("b"));
let tree = layout(&col, vp(100.0, 100.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
let b = tree.find(&"b".into()).unwrap().rect;
assert_eq!(a.min, Vec2::ZERO);
assert_eq!(a.max.y, 10.0);
assert_eq!(b.min.y, 12.0);
assert_eq!(b.max.y, 42.0);
}
#[test]
fn grid_two_by_three_makes_six_equal_cells() {
// 100x60 content, 2 cols × 3 rows, no gap → cells 50x20.
let grid = Widget::grid(2, 3)
.with_style(grow_both())
.with_children((0..6).map(|i| Widget::leaf(Vec2::ZERO).with_id(format!("c{i}"))));
let tree = layout(&grid, vp(100.0, 60.0), 1.0);
for i in 0..6 {
let row = i / 2;
let col = i % 2;
let n = tree.find(&format!("c{i}").into()).unwrap();
// Default FitContent of zero intrinsic ⇒ children collapse to
// (col*50, row*20)(col*50, row*20) at Start align inside the
// cell. Verify the *cell origin* via the node's `rect.min`.
assert_eq!(n.rect.min, Vec2::new(col as f32 * 50.0, row as f32 * 20.0));
}
}
#[test]
fn grid_gap_subtracts_from_cell_size() {
let grid = Widget::grid(2, 2)
.with_style(grow_both())
.with_grid_gap(Vec2::new(10.0, 10.0))
.with_children((0..4).map(|i| {
Widget::default()
.with_id(format!("c{i}"))
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
}));
let tree = layout(&grid, vp(110.0, 110.0), 1.0);
// (110 - 10 gap) / 2 = 50 per cell.
for i in 0..4 {
let n = tree.find(&format!("c{i}").into()).unwrap();
assert_eq!(n.rect.size(), Vec2::new(50.0, 50.0));
}
// Second column starts at 60 (50 + 10 gap).
assert_eq!(tree.find(&"c1".into()).unwrap().rect.min.x, 60.0);
// Second row starts at 60.
assert_eq!(tree.find(&"c2".into()).unwrap().rect.min.y, 60.0);
}
#[test]
fn anchor_fill_makes_child_match_parent_content() {
let parent = Widget::anchor()
.with_id("p")
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::ZERO).with_id("child"));
let tree = layout(&parent, vp(200.0, 100.0), 1.0);
let child = tree.find(&"child".into()).unwrap();
assert_eq!(
child.rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
);
}
#[test]
fn anchor_top_right_with_offsets_places_child_relative_to_corner() {
// Pin the child's top-right at the parent's top-right, then push the
// top-left corner 80 pixels left and 24 pixels down → 80×24 child in
// the top-right corner.
let parent = Widget::anchor()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::ZERO)
.with_id("c")
.with_style(LayoutStyle {
anchor: Anchor::TOP_RIGHT
.with_offsets(Vec2::new(-80.0, 0.0), Vec2::new(0.0, 24.0)),
..Default::default()
}),
);
let tree = layout(&parent, vp(300.0, 200.0), 1.0);
let c = tree.find(&"c".into()).unwrap().rect;
assert_eq!(c.min, Vec2::new(220.0, 0.0));
assert_eq!(c.max, Vec2::new(300.0, 24.0));
}
#[test]
fn anchor_dpi_scales_offsets() {
let parent = Widget::anchor()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::ZERO)
.with_id("c")
.with_style(LayoutStyle {
anchor: Anchor::TOP_LEFT.with_offsets(Vec2::ZERO, Vec2::new(40.0, 20.0)),
..Default::default()
}),
);
let tree = layout(&parent, vp(400.0, 400.0), 2.0);
let c = tree.find(&"c".into()).unwrap().rect;
assert_eq!(c.min, Vec2::ZERO);
assert_eq!(c.max, Vec2::new(80.0, 40.0));
}
#[test]
fn padding_shrinks_content_rect_and_offsets_children() {
let row = Widget::row()
.with_id("row")
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
padding: Insets::all(10.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("a"));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let row_node = tree.find(&"row".into()).unwrap();
assert_eq!(
row_node.rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
);
assert_eq!(
row_node.content_rect,
Rect::from_min_size(Vec2::splat(10.0), Vec2::new(180.0, 80.0))
);
let a = tree.find(&"a".into()).unwrap().rect;
assert_eq!(a.min, Vec2::splat(10.0));
assert_eq!(a.size(), Vec2::new(50.0, 20.0));
}
#[test]
fn margin_reserves_space_outside_widget() {
let row =
Widget::row().with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a").with_style(
LayoutStyle {
margin: Insets::symmetric(5.0, 0.0),
..Default::default()
},
));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
// 5px left margin → child starts at 5, width 40.
assert_eq!(a.min.x, 5.0);
assert_eq!(a.max.x, 45.0);
}
#[test]
fn fit_content_stack_sums_children_plus_padding() {
// Two 30x10 fixed children, no gap, padding=8 → root 76 x 26.
let row = Widget::row()
.with_id("root")
.with_style(LayoutStyle {
padding: Insets::all(8.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)))
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)));
let tree = layout(&row, vp(1000.0, 1000.0), 1.0);
let r = tree.find(&"root".into()).unwrap().rect;
assert_eq!(r.size(), Vec2::new(76.0, 26.0));
}
#[test]
fn layout_tree_round_trips_through_ron() {
let w = Widget::row()
.with_id("root")
.with_gap(4.0)
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"));
let tree = layout(&w, vp(100.0, 50.0), 1.0);
let text = ron::ser::to_string(&tree).unwrap();
let decoded: LayoutTree = ron::de::from_str(&text).unwrap();
assert_eq!(tree, decoded);
}
#[test]
fn find_rejects_empty_id() {
let w = Widget::leaf(Vec2::ONE);
let tree = layout(&w, vp(10.0, 10.0), 1.0);
assert!(tree.find(&WidgetId::default()).is_none());
}
#[test]
fn children_of_iterates_direct_children_only() {
let tree = layout(
&Widget::row()
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
.with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("inner")),
),
vp(100.0, 100.0),
1.0,
);
let ids: Vec<_> = tree
.children_of(0)
.map(|n| n.id.as_str().to_owned())
.collect();
assert_eq!(ids, vec!["a".to_string(), "col".to_string()]);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! In-game UI system — widget tree, layout, styling, text, input routing.
//!
//! Stage 8 builds the engine's **in-game** UI — what an exported game uses
//! to draw its menus, HUDs, and tools. This is intentionally distinct from
//! the editor's `egui` (which stays editor-only): a shipped game cannot pull
//! in `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 is shipped in pieces:
//!
//! 1. **Piece 1 — widget tree + layout (this module, right now).** A flat
//! [`Widget`] data structure, three layout modes ([`Stack`], [`Grid`],
//! [`AnchorGroup`]), and a pure-logic [`layout`] function that turns a
//! tree into a [`LayoutTree`] of resolved screen rects. No rendering,
//! no input, fully testable headlessly.
//! 2. Piece 2 — styling & theming (`Style` / `Theme` + RON dual-edit).
//! 3. Piece 3 — text shaping & glyph atlas.
//! 4. Piece 4 — 2D overlay render pass.
//! 5. Piece 5 — input routing (hit-test, hover/focus/press).
//! 6. Piece 6 — events + data binding.
//! 7. Pieces 79 — GUI tail (`examples/ui_menu`, `examples/ui_hud`, editor
//! UI canvas).
//!
//! # Worked example
//!
//! ```
//! use glam::Vec2;
//! use oxide_engine::math::Rect;
//! use oxide_engine::ui::{layout, Insets, LayoutStyle, Sizing, Widget};
//!
//! // A toolbar with two buttons and a stretching spacer between them.
//! 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::default()
//! .with_id("spacer")
//! .with_style(LayoutStyle {
//! width: Sizing::Grow(1.0),
//! height: Sizing::Grow(1.0),
//! ..Default::default()
//! }),
//! )
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("help"));
//!
//! let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(800.0, 600.0));
//! let tree = layout(&toolbar, viewport, 1.0);
//! let toolbar_rect = tree.root().unwrap().rect;
//! assert_eq!(toolbar_rect.height(), 32.0);
//! let help_rect = tree.find(&"help".into()).unwrap().rect;
//! assert_eq!(help_rect.max.x, 800.0 - 4.0); // padding on the right
//! ```
mod layout;
pub mod paint;
mod panel;
pub mod routing;
mod style;
pub mod text;
mod theme;
mod value;
mod visual;
mod widget;
pub use layout::{layout, LayoutNode, LayoutTree};
pub use paint::{paint, DrawCommand, PaintedFrame};
pub use panel::UiPanel;
pub use routing::{hit_test, Router, RouterEvent, RouterFrame};
pub use style::{Align, Anchor, Insets, LayoutStyle, Sizing};
pub use text::{
shape, shape_runs, AtlasEntry, Font, FontError, FontId, FontLoader, FontStore, GlyphAtlas,
GlyphId, GlyphKey, RasterizedGlyph, ShapeParams, ShapedGlyph, ShapedLine, ShapedText,
TextAlign, TextRun, TextStyle,
};
pub use theme::Theme;
pub use value::WidgetValue;
pub use visual::{Border, FontRef, FontWeight, VisualStyle};
pub use widget::{
AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind, WidgetPath,
};
+319
View File
@@ -0,0 +1,319 @@
//! Paint — turn a laid-out widget tree into a flat list of draw commands.
//!
//! Layout (piece 1) is purely geometric: rects in, rects out. Paint (piece 4)
//! adds the *visual* dimension: solid fills for backgrounds, textured quads
//! for text. The output is a [`PaintedFrame`] — a flat list of
//! [`DrawCommand`]s the [`UiOverlayPass`](super::super::render::UiOverlayPass)
//! consumes directly. Keeping paint pure-CPU and the GPU pass downstream
//! lets every paint test run headlessly; the GPU pass only has to know how
//! to *consume* commands, not how to derive them.
//!
//! # Algorithm
//!
//! 1. Walk the [`LayoutTree`] in node order (root first, children after).
//! 2. For each laid-out node:
//! - Resolve its [`VisualStyle`] under the active [`Theme`].
//! - If the resolved style has a background, emit one [`DrawCommand::Quad`]
//! filling `node.rect`.
//! - If the source widget has `text`, shape it inside `node.content_rect`
//! with the resolved font / size / color, then emit one
//! [`DrawCommand::Glyph`] per non-space glyph.
//! 3. The frame's overall `size` mirrors the layout root's rect so the GPU
//! pass knows how big the viewport for this batch is.
//!
//! Render order is the layout order: parents before children, so the
//! children draw *on top of* their parents (matching standard UI layering).
use glam::Vec2;
use super::layout::LayoutTree;
use super::text::{shape, FontStore, GlyphKey, ShapeParams, TextStyle};
use super::theme::Theme;
use super::visual::VisualStyle;
use super::widget::Widget;
use crate::math::{Color, Rect};
/// One draw call in a painted UI frame.
///
/// All commands share a single GPU pipeline and one texture (the glyph
/// atlas). Solid quads emit a sentinel UV the shader recognises as
/// "untextured" so a single fragment path handles both cases.
#[derive(Debug, Clone, PartialEq)]
pub enum DrawCommand {
/// A solid-colored axis-aligned rectangle.
Quad { rect: Rect, color: Color },
/// One glyph quad — the renderer turns the [`GlyphKey`] into an atlas
/// region at draw time. `pen_position` is the **baseline** point; the
/// atlas's per-glyph bearing positions the quad relative to it.
Glyph {
key: GlyphKey,
pen_position: Vec2,
color: Color,
},
}
/// Output of [`paint`] — the size of the painted area and the ordered list
/// of draw commands.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct PaintedFrame {
/// Size of the painted area in (post-scale) pixels — usually the
/// layout root's rect size.
pub size: Vec2,
/// Draw commands in the order they should be submitted (back-to-front).
pub commands: Vec<DrawCommand>,
}
/// Walk a laid-out widget tree under a theme and produce the draw commands
/// for one frame.
///
/// `scale` matches the value passed to
/// [`layout`](super::layout::layout) — paint uses it to pass the same DPI
/// factor to [`shape`] for text.
pub fn paint(
root: &Widget,
tree: &LayoutTree,
theme: &Theme,
fonts: &FontStore,
scale: f32,
) -> PaintedFrame {
let mut commands = Vec::new();
paint_widget(root, tree, 0, theme, fonts, scale, &mut commands);
let size = tree
.root()
.map(|node| node.rect.size())
.unwrap_or(Vec2::ZERO);
PaintedFrame { size, commands }
}
fn paint_widget(
widget: &Widget,
tree: &LayoutTree,
node_index: usize,
theme: &Theme,
fonts: &FontStore,
scale: f32,
out: &mut Vec<DrawCommand>,
) {
let node = &tree.nodes()[node_index];
let resolved = widget.resolve_visual(theme);
// Background fill — only emit if the rect has area and a background was
// resolved. A `corner_radius` is captured in the resolved style for
// future use but ignored by piece-4's rectangular renderer.
if let Some(bg) = resolved.background {
if !node.rect.is_empty() {
out.push(DrawCommand::Quad {
rect: node.rect,
color: bg,
});
}
}
// Text — shape inside `content_rect` (so padding is respected) and emit
// one glyph per non-empty position.
if let Some(text) = widget.text.as_ref() {
paint_text(text, node.content_rect, &resolved, fonts, scale, out);
}
// Children draw on top of self.
for (child_widget, child_index) in widget.children().iter().zip(node.children.iter()) {
paint_widget(
child_widget,
tree,
*child_index as usize,
theme,
fonts,
scale,
out,
);
}
}
fn paint_text(
text: &str,
content_rect: Rect,
resolved: &VisualStyle,
fonts: &FontStore,
scale: f32,
out: &mut Vec<DrawCommand>,
) {
let Some(font_ref) = resolved.font.as_ref() else {
return;
};
let Some(font_id) = fonts.resolve(font_ref) else {
return;
};
let size_px = resolved.font_size.unwrap_or(14.0);
let color = resolved.foreground.unwrap_or(Color::BLACK);
let style = TextStyle {
font: font_id,
size_px,
};
let params = ShapeParams {
max_width: Some(content_rect.width()),
scale,
..ShapeParams::default()
};
let shaped = shape(text, style, &params, fonts);
for line in &shaped.lines {
for g in &line.glyphs {
out.push(DrawCommand::Glyph {
key: g.key,
pen_position: content_rect.min + g.position,
color,
});
}
}
}
#[cfg(test)]
mod tests {
use super::super::layout::layout;
use super::super::text::{common_system_font_paths, Font};
use super::super::visual::FontRef;
use super::super::widget::Widget;
use super::*;
use glam::Vec2;
fn viewport(w: f32, h: f32) -> Rect {
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
}
fn solid_panel(color: Color, w: f32, h: f32) -> Widget {
Widget::leaf(Vec2::new(w, h)).with_visual(VisualStyle {
background: Some(color),
..VisualStyle::EMPTY
})
}
#[test]
fn solid_widget_emits_one_quad_at_its_rect() {
let root = solid_panel(Color::RED, 40.0, 20.0);
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
let theme = Theme::new();
let fonts = FontStore::new();
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
assert_eq!(painted.size, Vec2::new(40.0, 20.0));
assert_eq!(painted.commands.len(), 1);
match &painted.commands[0] {
DrawCommand::Quad { rect, color } => {
assert_eq!(
*rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
);
assert_eq!(*color, Color::RED);
}
_ => panic!("expected a Quad"),
}
}
#[test]
fn widget_without_visual_emits_no_quads() {
// Default widget has empty visual — nothing to paint.
let root = Widget::leaf(Vec2::new(40.0, 20.0));
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
assert!(painted.commands.is_empty());
}
#[test]
fn child_quad_is_emitted_after_parent_quad() {
let root = Widget::row()
.with_visual(VisualStyle {
background: Some(Color::WHITE),
..VisualStyle::EMPTY
})
.with_style(super::super::style::LayoutStyle {
width: super::super::style::Sizing::Fixed(100.0),
height: super::super::style::Sizing::Fixed(50.0),
..Default::default()
})
.with_child(solid_panel(Color::RED, 40.0, 20.0));
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
assert_eq!(painted.commands.len(), 2);
// Parent (white) painted before child (red), so child draws on top.
match &painted.commands[0] {
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::WHITE),
_ => panic!(),
}
match &painted.commands[1] {
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::RED),
_ => panic!(),
}
}
fn try_load_font() -> Option<Font> {
for path in common_system_font_paths() {
if std::path::Path::new(path).exists() {
if let Ok(font) = Font::from_path(path) {
return Some(font);
}
}
}
eprintln!("SKIP: no system font available for paint tests");
None
}
#[test]
fn text_emits_one_glyph_per_visible_char() {
let Some(font) = try_load_font() else {
return;
};
let descriptor = FontRef::regular("Sys");
let mut fonts = FontStore::new();
fonts.insert_with_descriptor(descriptor.clone(), font);
let theme = Theme::new().with_default(VisualStyle {
font: Some(descriptor),
font_size: Some(14.0),
foreground: Some(Color::BLACK),
..VisualStyle::EMPTY
});
let root = Widget::leaf(Vec2::new(80.0, 20.0))
.with_id("label")
.with_text("Hi")
.with_style(super::super::style::LayoutStyle {
width: super::super::style::Sizing::Fixed(80.0),
height: super::super::style::Sizing::Fixed(20.0),
..Default::default()
});
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
// "Hi" → 2 glyphs (H, i). No background → no Quad commands.
let glyph_count = painted
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Glyph { .. }))
.count();
let quad_count = painted
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Quad { .. }))
.count();
assert_eq!(glyph_count, 2);
assert_eq!(quad_count, 0);
// Both glyphs sit at the same baseline.
let baselines: Vec<f32> = painted
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Glyph { pen_position, .. } => Some(pen_position.y),
_ => None,
})
.collect();
assert_eq!(baselines[0], baselines[1]);
}
#[test]
fn text_without_font_in_theme_silently_emits_nothing() {
// No font registered → text resolves but shape returns no lines.
// Paint must not panic.
let root = Widget::leaf(Vec2::new(40.0, 20.0)).with_text("Hi");
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
assert!(painted.commands.is_empty());
}
}
+231
View File
@@ -0,0 +1,231 @@
//! World-space UI panels — a [`Widget`] tree rendered onto a quad in 3D.
//!
//! Stage 8's UI is "in-game UI" — what the exported game uses to draw
//! menus and HUDs. Most of the time those are **screen-space**: pixel-
//! anchored, drawn over the 3D scene by piece 4a's
//! [`UiOverlayPass`](super::super::render::UiOverlayPass) using an
//! orthographic projection. A [`UiPanel`] is the world-space alternative —
//! the same `Widget` tree, but laid out on a flat panel that sits in the
//! 3D world at some [`Transform`].
//!
//! This is what gives game projects:
//!
//! - **Diegetic UI** — terminal screens, signs, dashboards, control
//! panels — the player sees them rendered inside the world rather than
//! pasted over it.
//! - **Editor previews** — the UI canvas (piece 9) can drop a panel into
//! the scene to preview a document at scale, on the same hardware path
//! the shipped game uses.
//! - **VR / room-scale UI** later — once Stage-13 head-mounted display
//! support lands, world-space panels are the only sensible way to
//! present interactive UI.
//!
//! # How the math works
//!
//! A panel describes itself in two coordinate spaces:
//!
//! - **Pixel space** — where the layout algorithm operates. `pixel_size`
//! is the resolution the `Widget` tree is laid out at (e.g.,
//! `Vec2::new(1024.0, 768.0)`). Glyphs are rasterized at this scale.
//! - **World space** — where the panel sits in 3D. `world_size` is its
//! physical size in world units (e.g., `Vec2::new(2.0, 1.5)` for a
//! 2 m × 1.5 m monitor).
//!
//! The piece-4 vertex format carries 2D pixel-space positions. To draw
//! that on a 3D quad, [`UiBatch::world_space`](super::super::render::UiBatch::world_space)
//! builds a single MVP that composes:
//!
//! ```text
//! mvp = camera_view_projection
//! * panel_transform // world placement
//! * scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (and flip y, since UI is y-down)
//! * translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
//! ```
//!
//! The same `UiOverlayPass` then draws the panel using the same shader
//! and the same R8 atlas — the only thing that distinguishes a screen-
//! space batch from a world-space one is which constructor built it.
//!
//! # Overlay semantics for piece 4b
//!
//! World-space panels in piece 4b render as **overlays**: no depth test,
//! no depth write — they draw on top of whatever's already in the color
//! 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`](../../../../PLAN.md)'s Stage-8 backlog and
//! slots in by attaching a depth attachment to a second pass of the
//! same pipeline.
use glam::Mat4;
use serde::{Deserialize, Serialize};
use super::layout::layout;
use super::paint::paint;
use super::text::FontStore;
use super::theme::Theme;
use super::widget::Widget;
use crate::math::{Rect, Transform, Vec2};
/// A widget tree placed on a 3D quad.
///
/// `UiPanel` carries pure data: the document, its pixel resolution, and
/// its world size. The host owns the panel's [`Transform`] separately
/// (typically as an ECS component on the same entity), the active
/// [`Theme`], and the [`FontStore`] — all three are needed at render
/// time to build the panel's [`UiBatch`](super::super::render::UiBatch).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UiPanel {
/// The UI document on this panel.
pub root: Widget,
/// Resolution to lay out the UI at, in logical pixels. Drives the
/// pixel size of every glyph rasterization (so a higher
/// `pixel_size.x` on the same `world_size.x` produces a crisper
/// panel at a cost of more atlas memory).
pub pixel_size: Vec2,
/// Panel dimensions in world units. Together with `pixel_size` this
/// gives the pixels-per-world-unit ratio the MVP uses.
pub world_size: Vec2,
}
impl UiPanel {
/// Build a panel with the given UI document and dimensions. Equivalent
/// to the struct literal; kept as a function so the API can grow
/// validation later without breaking callers.
pub fn new(root: Widget, pixel_size: Vec2, world_size: Vec2) -> Self {
Self {
root,
pixel_size,
world_size,
}
}
/// Convenience: lay out + paint this panel and build the
/// [`UiBatch`](super::super::render::UiBatch) the
/// [`UiOverlayPass`](super::super::render::UiOverlayPass) consumes.
///
/// Returns `None` if the panel's `pixel_size` is non-positive — the
/// caller didn't configure the panel and there's no meaningful
/// rendering to do.
pub fn build_batch(
&self,
theme: &Theme,
fonts: &FontStore,
panel_transform: &Transform,
view_projection: Mat4,
) -> Option<super::super::render::UiBatch> {
if self.pixel_size.x <= 0.0 || self.pixel_size.y <= 0.0 {
return None;
}
let viewport = Rect::from_min_size(Vec2::ZERO, self.pixel_size);
let tree = layout(&self.root, viewport, 1.0);
let painted = paint(&self.root, &tree, theme, fonts, 1.0);
Some(super::super::render::UiBatch::world_space(
painted,
self.pixel_size,
self.world_size,
panel_transform,
view_projection,
))
}
}
#[cfg(test)]
mod tests {
use super::super::style::{LayoutStyle, Sizing};
use super::super::visual::VisualStyle;
use super::*;
use crate::math::{Color, Vec3};
#[test]
fn build_batch_returns_none_on_zero_pixel_size() {
let panel = UiPanel::new(Widget::default(), Vec2::ZERO, Vec2::new(2.0, 2.0));
let theme = Theme::new();
let fonts = FontStore::new();
let result = panel.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY);
assert!(result.is_none());
}
#[test]
fn build_batch_succeeds_with_valid_panel() {
let root = Widget::default()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
});
let panel = UiPanel::new(root, Vec2::new(256.0, 128.0), Vec2::new(2.0, 1.0));
let theme = Theme::new();
let fonts = FontStore::new();
let batch = panel
.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY)
.expect("valid panel should build a batch");
// The batch's painted frame matches the panel's pixel size and has
// one Quad command (the red background).
assert_eq!(batch.frame.size, Vec2::new(256.0, 128.0));
assert_eq!(batch.frame.commands.len(), 1);
}
#[test]
fn panel_round_trips_through_ron() {
let panel = UiPanel::new(
Widget::row().with_id("hud").with_visual(VisualStyle {
background: Some(Color::WHITE),
..VisualStyle::EMPTY
}),
Vec2::new(1024.0, 768.0),
Vec2::new(4.0, 3.0),
);
let text = ron::ser::to_string_pretty(&panel, ron::ser::PrettyConfig::default()).unwrap();
let decoded: UiPanel = ron::de::from_str(&text).unwrap();
assert_eq!(panel, decoded);
}
#[test]
fn identity_mvp_keeps_pixel_origin_at_panel_centre() {
// Sanity: with an identity view-projection and default panel
// transform, a vertex at (0, 0) in pixel space lands at the top-
// left of the panel in world space, which under our MVP becomes
// (-world.x/2, +world.y/2, 0) (y-down → y-up).
let panel = UiPanel::new(
Widget::default()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
}),
Vec2::new(2.0, 2.0),
Vec2::new(2.0, 2.0),
);
let batch = panel
.build_batch(
&Theme::new(),
&FontStore::new(),
&Transform::default(),
Mat4::IDENTITY,
)
.unwrap();
// Apply the MVP to the pixel-space top-left (0, 0, 0, 1).
let top_left = batch.mvp * Vec3::new(0.0, 0.0, 0.0).extend(1.0);
assert!(
(top_left.x - -1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5,
"top-left should map to (-1, 1) under identity MVP, got {top_left:?}"
);
// Bottom-right pixel maps to (+world.x/2, -world.y/2).
let bottom_right = batch.mvp * Vec3::new(2.0, 2.0, 0.0).extend(1.0);
assert!(
(bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y - -1.0).abs() < 1e-5,
"bottom-right should map to (1, -1), got {bottom_right:?}"
);
}
}
+642
View File
@@ -0,0 +1,642 @@
//! Input routing — hit-test the UI against the cursor, track hover / press /
//! focus per widget, and tell the host whether the UI captured the frame's
//! input so the game can decide whether to also handle it.
//!
//! Stage 8's UI must *consume input before the game* (PLAN.md): if the
//! cursor is over a button, clicking shouldn't also fire the game-world
//! action bound to that mouse button. The [`Router`] gives the host one
//! object to drive each frame:
//!
//! ```text
//! game loop:
//! input.handle_event(e); ...
//! let frame = router.process(&layout_tree, &input);
//! if !frame.captured_mouse { /* game receives mouse input */ }
//! if !frame.captured_keyboard { /* game receives keys */ }
//! for event in &frame.events { /* run widget callbacks (piece 6) */ }
//! ```
//!
//! The router is purely a state machine over the Stage-7 [`InputState`] and
//! the Stage-8 [`LayoutTree`] — no GPU, no widget callbacks (those land in
//! piece 6). Tests run headlessly.
//!
//! # Hit-test order
//!
//! Hit testing walks [`LayoutTree::nodes`] in **reverse order**. That order
//! matches the paint order (parents-before-children, earlier siblings
//! before later ones — see [`super::paint`]) — so the *last* node drawn
//! is the *first* one tested, which is exactly the topmost interactive
//! widget under the cursor.
//!
//! Anonymous widgets (`WidgetId::default()`) are treated as transparent
//! for hit-test purposes: the router skips them and looks deeper, so a
//! decorative container without an id doesn't block clicks reaching the
//! button inside it.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use winit::event::MouseButton;
use super::layout::{LayoutNode, LayoutTree};
use super::widget::WidgetId;
use crate::input::InputState;
use crate::math::Vec2;
/// One event emitted by [`Router::process`] for the current frame.
///
/// Events are ordered: hover changes come first, then per-button press /
/// release / click, then focus changes. Callers in piece 6 will dispatch
/// each event to the matching widget's registered callback.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RouterEvent {
/// The cursor moved onto this widget this frame.
Hovered(WidgetId),
/// The cursor moved off this widget this frame.
Unhovered(WidgetId),
/// A mouse button was pressed while the cursor was over this widget.
Pressed(WidgetId, MouseButton),
/// A mouse button was released while the cursor was over this widget.
/// May or may not be accompanied by a [`Clicked`](Self::Clicked); see
/// the comment on that variant.
Released(WidgetId, MouseButton),
/// A click completed on this widget: the press *and* release happened
/// over the same widget without the cursor leaving in between.
/// Dragging off cancels the click.
Clicked(WidgetId, MouseButton),
/// This widget became the focused widget.
FocusGained(WidgetId),
/// This widget lost focus.
FocusLost(WidgetId),
}
/// What [`Router::process`] produces for one frame.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct RouterFrame {
/// Events emitted this frame, in the order they were observed.
pub events: Vec<RouterEvent>,
/// `true` if the cursor is over any (non-anonymous) widget — the game
/// should not also process this frame's mouse input.
pub captured_mouse: bool,
/// `true` if a widget currently has keyboard focus — the game should
/// not also process this frame's key events.
pub captured_keyboard: bool,
}
impl RouterFrame {
/// `true` if this frame contains a `Clicked` event on `id` for the
/// given mouse button. The immediate-mode pattern: game code calls
/// `if frame.clicked("play", MouseButton::Left) { start_game() }`
/// instead of registering a callback.
pub fn clicked(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Clicked(w, b) => w.as_str() == id && *b == button,
_ => false,
})
}
/// Shorthand for [`clicked`](Self::clicked) with the left button.
pub fn clicked_left(&self, id: impl AsRef<str>) -> bool {
self.clicked(id, MouseButton::Left)
}
/// `true` if this frame contains a `Pressed` event on `id` with the
/// given mouse button.
pub fn pressed(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Pressed(w, b) => w.as_str() == id && *b == button,
_ => false,
})
}
/// `true` if this frame contains a `Released` event on `id` with the
/// given mouse button.
pub fn released(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Released(w, b) => w.as_str() == id && *b == button,
_ => false,
})
}
/// `true` if the cursor entered `id` this frame.
pub fn hovered_in(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Hovered(w) => w.as_str() == id,
_ => false,
})
}
/// `true` if the cursor left `id` this frame.
pub fn hovered_out(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Unhovered(w) => w.as_str() == id,
_ => false,
})
}
/// `true` if `id` gained focus this frame.
pub fn focus_gained(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::FocusGained(w) => w.as_str() == id,
_ => false,
})
}
/// `true` if `id` lost focus this frame.
pub fn focus_lost(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::FocusLost(w) => w.as_str() == id,
_ => false,
})
}
}
/// Per-widget input state machine. Persists hover / focus / pending-press
/// across frames so click-detection (press *and* release on the same
/// widget) works correctly across the multiple frames a click typically
/// spans.
#[derive(Debug, Default)]
pub struct Router {
hovered: Option<WidgetId>,
focused: Option<WidgetId>,
/// Per-button: the widget that received the most recent un-released
/// press. A click completes if the release happens over the same
/// widget; otherwise the press is cancelled (drag-off semantics).
pending: HashMap<MouseButton, WidgetId>,
}
impl Router {
/// Build an empty router with no hover, no focus, and no pending presses.
pub fn new() -> Self {
Self::default()
}
/// The currently hovered widget, or `None` when the cursor is not over
/// any addressable widget.
pub fn hovered(&self) -> Option<&WidgetId> {
self.hovered.as_ref()
}
/// The currently focused widget, or `None` if none.
pub fn focused(&self) -> Option<&WidgetId> {
self.focused.as_ref()
}
/// Explicitly focus a widget (e.g., from game code after opening a
/// menu). Emits no event — the caller decided to do this.
pub fn set_focused(&mut self, id: Option<WidgetId>) {
self.focused = id;
}
/// Run the input pipeline against one frame's [`InputState`] and the
/// current [`LayoutTree`]. Updates internal state, returns events plus
/// the capture flags.
pub fn process(&mut self, tree: &LayoutTree, input: &InputState) -> RouterFrame {
let mut frame = RouterFrame::default();
let new_hover = input
.cursor()
.and_then(|c| hit_test(tree, c))
.map(|node| node.id.clone());
// Hover transitions.
if new_hover != self.hovered {
if let Some(old) = self.hovered.take() {
frame.events.push(RouterEvent::Unhovered(old));
}
if let Some(new) = new_hover.clone() {
frame.events.push(RouterEvent::Hovered(new));
}
}
self.hovered = new_hover;
frame.captured_mouse = self.hovered.is_some();
// Mouse press / release per button. The Stage-7 InputState
// exposes "buttons held" + per-button edge flags; we walk the
// currently-relevant buttons (those held this frame *or* present
// as pending from previous frames).
let mut buttons = std::collections::HashSet::new();
buttons.extend(input.mouse_buttons_held());
buttons.extend(self.pending.keys().copied());
// Common buttons that may have just pressed/released without being
// held now (release edge happens after the held set has cleared
// the button).
for b in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
if input.mouse_pressed(b) || input.mouse_released(b) {
buttons.insert(b);
}
}
for button in buttons {
if input.mouse_pressed(button) {
if let Some(target) = self.hovered.clone() {
frame
.events
.push(RouterEvent::Pressed(target.clone(), button));
self.pending.insert(button, target.clone());
self.update_focus(Some(target), &mut frame);
} else {
// Click outside any widget clears focus.
self.update_focus(None, &mut frame);
}
}
if input.mouse_released(button) {
if let Some(pending_id) = self.pending.remove(&button) {
if let Some(current) = self.hovered.clone() {
frame
.events
.push(RouterEvent::Released(current.clone(), button));
if current == pending_id {
frame.events.push(RouterEvent::Clicked(current, button));
}
} else {
// Drag-off then release: cancel the click. No
// Released event has a target either, since we
// require a hovered widget for that.
}
}
}
}
frame.captured_keyboard = self.focused.is_some();
frame
}
/// Move focus to `next` (or clear it when `None`), emitting `FocusLost`
/// / `FocusGained` events. Idempotent when `next` matches the current
/// focus.
fn update_focus(&mut self, next: Option<WidgetId>, frame: &mut RouterFrame) {
if next == self.focused {
return;
}
if let Some(old) = self.focused.take() {
frame.events.push(RouterEvent::FocusLost(old));
}
if let Some(new) = next.clone() {
frame.events.push(RouterEvent::FocusGained(new));
}
self.focused = next;
}
}
/// Hit-test `point` against the laid-out widgets. Returns the topmost
/// (most-recently-painted) [`LayoutNode`] with a non-empty id whose `rect`
/// contains the point, or `None` if no addressable widget is under the
/// point.
///
/// Anonymous widgets (empty `id`) are skipped so a decorative container
/// doesn't block hits on the button it contains. Iteration is in reverse
/// node order — children and later siblings (drawn on top) are tested
/// before their parents.
pub fn hit_test(tree: &LayoutTree, point: Vec2) -> Option<&LayoutNode> {
for node in tree.nodes().iter().rev() {
if node.id.is_empty() {
continue;
}
if node.rect.contains_point(point) {
return Some(node);
}
}
None
}
#[cfg(test)]
mod tests {
use super::super::layout::layout;
use super::super::style::{LayoutStyle, Sizing};
use super::super::widget::Widget;
use super::*;
use crate::math::Rect;
fn viewport(w: f32, h: f32) -> Rect {
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
}
fn make_tree() -> (Widget, LayoutTree) {
// Root container with two side-by-side leaves: "left" and "right".
let root = Widget::row()
.with_id("root")
.with_style(LayoutStyle {
width: Sizing::Fixed(200.0),
height: Sizing::Fixed(100.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::new(100.0, 100.0))
.with_id("left")
.with_style(LayoutStyle {
width: Sizing::Fixed(100.0),
height: Sizing::Fixed(100.0),
..Default::default()
}),
)
.with_child(
Widget::leaf(Vec2::new(100.0, 100.0))
.with_id("right")
.with_style(LayoutStyle {
width: Sizing::Fixed(100.0),
height: Sizing::Fixed(100.0),
..Default::default()
}),
);
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
(root, tree)
}
#[test]
fn hit_test_returns_topmost_widget_with_id() {
let (_root, tree) = make_tree();
// Cursor over the left child → returns "left", not "root".
let hit = hit_test(&tree, Vec2::new(50.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "left");
// Cursor over the right child → "right".
let hit = hit_test(&tree, Vec2::new(150.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "right");
}
#[test]
fn hit_test_falls_back_to_parent_when_children_dont_cover() {
// Root 200×100 with 20-pixel padding, containing one 80×60 button.
// The padding gutter is "root-only" space — clicks there should
// resolve to "root", not the button.
let root = Widget::row()
.with_id("root")
.with_style(LayoutStyle {
width: Sizing::Fixed(200.0),
height: Sizing::Fixed(100.0),
padding: super::super::style::Insets::all(20.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::new(80.0, 60.0))
.with_id("button")
.with_style(LayoutStyle {
width: Sizing::Fixed(80.0),
height: Sizing::Fixed(60.0),
..Default::default()
}),
);
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
// Inside the button.
let hit = hit_test(&tree, Vec2::new(60.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "button");
// Inside root's padding gutter (10, 50) → root, not button.
let hit = hit_test(&tree, Vec2::new(10.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "root");
}
#[test]
fn hit_test_skips_anonymous_widgets() {
// A button buried inside two anonymous containers should still hit.
let root = Widget::row()
.with_style(LayoutStyle {
width: Sizing::Fixed(200.0),
height: Sizing::Fixed(100.0),
..Default::default()
})
.with_child(
Widget::row()
.with_style(LayoutStyle {
width: Sizing::Fixed(100.0),
height: Sizing::Fixed(100.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::new(80.0, 80.0))
.with_id("button")
.with_style(LayoutStyle {
width: Sizing::Fixed(80.0),
height: Sizing::Fixed(80.0),
..Default::default()
}),
),
);
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
let hit = hit_test(&tree, Vec2::new(20.0, 20.0)).unwrap();
assert_eq!(hit.id.as_str(), "button");
}
#[test]
fn hit_test_returns_none_outside_root() {
let (_root, tree) = make_tree();
let hit = hit_test(&tree, Vec2::new(500.0, 500.0));
assert!(hit.is_none());
}
#[test]
fn cursor_moving_onto_widget_emits_hovered_event() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
// First frame: cursor outside, no hover.
input.set_cursor(Vec2::new(500.0, 500.0));
let f = router.process(&tree, &input);
assert!(f.events.is_empty());
assert!(!f.captured_mouse);
// Move into the left widget.
input.set_cursor(Vec2::new(50.0, 50.0));
let f = router.process(&tree, &input);
assert_eq!(f.events, vec![RouterEvent::Hovered("left".into())]);
assert!(f.captured_mouse);
assert_eq!(router.hovered(), Some(&"left".into()));
}
#[test]
fn cursor_moving_off_emits_unhovered() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
// Move off the widget.
input.set_cursor(Vec2::new(500.0, 500.0));
let f = router.process(&tree, &input);
assert_eq!(f.events, vec![RouterEvent::Unhovered("left".into())]);
assert!(!f.captured_mouse);
}
#[test]
fn cursor_moving_between_widgets_swaps_hover() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.set_cursor(Vec2::new(150.0, 50.0));
let f = router.process(&tree, &input);
// Unhover left, then hover right (both this frame).
assert_eq!(
f.events,
vec![
RouterEvent::Unhovered("left".into()),
RouterEvent::Hovered("right".into()),
]
);
}
#[test]
fn pressing_over_widget_emits_pressed_and_focuses() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
// Frame 1: hover only.
router.process(&tree, &input);
// Frame 2: press the left button while hovering.
input.press_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
assert!(f
.events
.contains(&RouterEvent::Pressed("left".into(), MouseButton::Left)));
assert!(f.events.contains(&RouterEvent::FocusGained("left".into())));
assert_eq!(router.focused(), Some(&"left".into()));
assert!(f.captured_keyboard);
}
#[test]
fn press_then_release_on_same_widget_emits_click() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.press_mouse(MouseButton::Left);
router.process(&tree, &input);
input.end_frame(); // clear the press edge
input.release_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
// Released and Clicked, both on "left".
assert!(f
.events
.contains(&RouterEvent::Released("left".into(), MouseButton::Left)));
assert!(f
.events
.contains(&RouterEvent::Clicked("left".into(), MouseButton::Left)));
}
#[test]
fn press_then_drag_off_then_release_does_not_emit_click() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.press_mouse(MouseButton::Left);
router.process(&tree, &input);
input.end_frame();
// Drag onto the right widget, then release.
input.set_cursor(Vec2::new(150.0, 50.0));
input.release_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
let clicked = f
.events
.iter()
.any(|e| matches!(e, RouterEvent::Clicked(_, _)));
assert!(!clicked, "drag-off should cancel the click");
}
#[test]
fn pressing_outside_any_widget_clears_focus() {
let (_root, tree) = make_tree();
let mut router = Router::new();
router.set_focused(Some("left".into()));
let mut input = InputState::new();
input.set_cursor(Vec2::new(500.0, 500.0));
input.press_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
assert!(f.events.contains(&RouterEvent::FocusLost("left".into())));
assert_eq!(router.focused(), None);
}
#[test]
fn captured_flags_match_state() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
// No cursor, no focus → nothing captured.
let f = router.process(&tree, &input);
assert!(!f.captured_mouse);
assert!(!f.captured_keyboard);
// Cursor over a widget → captures mouse.
input.set_cursor(Vec2::new(50.0, 50.0));
let f = router.process(&tree, &input);
assert!(f.captured_mouse);
assert!(!f.captured_keyboard);
// Press → focuses, captures keyboard too.
input.press_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
assert!(f.captured_keyboard);
}
#[test]
fn cursor_off_screen_does_not_hover() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let input = InputState::new(); // cursor unset
let f = router.process(&tree, &input);
assert!(f.events.is_empty());
assert!(!f.captured_mouse);
}
#[test]
fn router_frame_clicked_query_matches_button_and_id() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.press_mouse(MouseButton::Left);
router.process(&tree, &input);
input.end_frame();
input.release_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
// Immediate-mode query: was "left" clicked with Left?
assert!(f.clicked_left("left"));
assert!(f.clicked("left", MouseButton::Left));
// Different id or different button → false.
assert!(!f.clicked_left("right"));
assert!(!f.clicked("left", MouseButton::Right));
}
#[test]
fn router_frame_query_methods_cover_each_event_kind() {
// Build a frame manually with one of each event variant and
// verify each query method matches exactly one.
let f = RouterFrame {
events: vec![
RouterEvent::Hovered("a".into()),
RouterEvent::Unhovered("b".into()),
RouterEvent::Pressed("c".into(), MouseButton::Right),
RouterEvent::Released("d".into(), MouseButton::Middle),
RouterEvent::Clicked("e".into(), MouseButton::Left),
RouterEvent::FocusGained("f".into()),
RouterEvent::FocusLost("g".into()),
],
captured_mouse: true,
captured_keyboard: true,
};
assert!(f.hovered_in("a"));
assert!(f.hovered_out("b"));
assert!(f.pressed("c", MouseButton::Right));
assert!(f.released("d", MouseButton::Middle));
assert!(f.clicked("e", MouseButton::Left));
assert!(f.focus_gained("f"));
assert!(f.focus_lost("g"));
// Negative checks.
assert!(!f.hovered_in("b"));
assert!(!f.clicked_left("c")); // Pressed, not Clicked
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Layout style primitives — sizing, padding, margin, alignment, and anchors.
//!
//! Every Stage-8 widget carries a [`LayoutStyle`] that tells the layout
//! algorithm how to size and position it inside its parent's content rect.
//! The primitives here are deliberately small and orthogonal so they compose
//! into the three layout modes (stack, grid, anchor) without each mode
//! introducing its own bespoke parameters.
//!
//! All linear measurements (`Sizing::Fixed`, [`Insets`] fields, anchor
//! offsets, stack/grid gaps) are in **logical pixels**. The layout function
//! takes a separate `scale` factor (typically the window's DPI scale) and
//! multiplies these values at resolve time, so one widget tree lays out
//! sensibly on a 1× laptop and a 2× HiDPI monitor without per-widget rewrites.
use glam::Vec2;
use serde::{Deserialize, Serialize};
/// How a widget asks to be sized along one axis.
///
/// Sizing interacts with the parent's layout mode:
///
/// - In a stack, the **main axis** sums all `Fixed` and `FitContent` sizes,
/// then divides leftover space among `Grow` siblings by weight. The
/// **cross axis** sizes each child independently (`Grow` fills the parent's
/// cross extent; the other variants behave like the main axis).
/// - In a grid, every child fills its cell, but `Fixed`/`FitContent` cap the
/// child's drawn size and let [`LayoutStyle::align_horizontal`] /
/// [`LayoutStyle::align_vertical`] position the smaller rect inside the
/// cell.
/// - In an anchor parent, child sizing is **ignored** along axes the anchor
/// actually constrains; the anchor + offsets fully determine the child's
/// rect.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub enum Sizing {
/// A fixed size in logical pixels. Multiplied by the layout scale factor.
Fixed(f32),
/// Take a share of the parent's leftover space, weighted by `f32`.
///
/// Two siblings with `Grow(1.0)` split leftover space evenly; `Grow(2.0)`
/// next to `Grow(1.0)` takes 2/3 of it. A non-positive weight contributes
/// nothing and the child collapses to zero on that axis.
Grow(f32),
/// Size to fit the widget's own content — the intrinsic size for leaves,
/// the recursive content extent for containers.
#[default]
FitContent,
}
/// Per-side spacing in logical pixels — used for both padding (inside) and
/// margin (outside).
///
/// Padding shrinks a widget's `content_rect` (children draw inside it); margin
/// reserves space *around* the widget so siblings don't touch it. Both are
/// scaled by the layout scale factor at resolve time.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct Insets {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
impl Insets {
pub const ZERO: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
};
/// Same value on every side.
pub const fn all(v: f32) -> Self {
Self {
left: v,
right: v,
top: v,
bottom: v,
}
}
/// Symmetric: one value for left+right, another for top+bottom.
pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
Self {
left: horizontal,
right: horizontal,
top: vertical,
bottom: vertical,
}
}
/// Combined horizontal extent (`left + right`).
#[inline]
pub fn horizontal(&self) -> f32 {
self.left + self.right
}
/// Combined vertical extent (`top + bottom`).
#[inline]
pub fn vertical(&self) -> f32 {
self.top + self.bottom
}
/// Component-wise scale (used internally by the layout algorithm to apply
/// the DPI factor; exposed for tests that want to verify the scaling).
#[inline]
pub fn scaled(&self, scale: f32) -> Self {
Self {
left: self.left * scale,
right: self.right * scale,
top: self.top * scale,
bottom: self.bottom * scale,
}
}
}
/// Alignment along one axis when a widget is smaller than its slot.
///
/// In a row stack, `align_vertical` decides whether a short child docks to the
/// top, middle, or bottom of the row's content rect. The stack's own
/// [`Stack::main_align`](super::widget::Stack::main_align) does the analogous
/// thing along the **main** axis when all children are sized but don't sum to
/// the full main extent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Align {
/// Top / left edge.
#[default]
Start,
/// Centered in the available space.
Center,
/// Bottom / right edge.
End,
}
/// How a child positions itself inside an [`AnchorGroup`](super::widget::AnchorGroup)
/// parent.
///
/// Anchors are two normalized points in `[0, 1]²` (the **anchor rectangle**)
/// plus per-corner offsets in logical pixels. The child's resulting rect is:
///
/// ```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
/// ```
///
/// This is the standard Unity / Godot anchor formulation: pick two anchor
/// corners (a single point for "follow that corner", a full rectangle for
/// "dock to this edge / fill"), then nudge with offsets. The default is
/// [`Anchor::FILL`].
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
pub min: Vec2,
pub max: Vec2,
pub offset_min: Vec2,
pub offset_max: Vec2,
}
impl Anchor {
/// Fill the parent's content rect exactly. The default for new widgets.
pub const FILL: Self = Self {
min: Vec2::ZERO,
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the top-left corner with `offset_max` controlling the child's
/// size (which is otherwise zero because `min == max`).
pub const TOP_LEFT: Self = Self {
min: Vec2::ZERO,
max: Vec2::ZERO,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the top-right corner.
pub const TOP_RIGHT: Self = Self {
min: Vec2::new(1.0, 0.0),
max: Vec2::new(1.0, 0.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the bottom-left corner.
pub const BOTTOM_LEFT: Self = Self {
min: Vec2::new(0.0, 1.0),
max: Vec2::new(0.0, 1.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the bottom-right corner.
pub const BOTTOM_RIGHT: Self = Self {
min: Vec2::ONE,
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the top edge — full width, child height controlled by
/// `offset_max.y`.
pub const TOP: Self = Self {
min: Vec2::ZERO,
max: Vec2::new(1.0, 0.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the bottom edge — full width, child height controlled by
/// `offset_min.y` (negative pushes the top edge upward).
pub const BOTTOM: Self = Self {
min: Vec2::new(0.0, 1.0),
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the left edge — full height, child width via `offset_max.x`.
pub const LEFT: Self = Self {
min: Vec2::ZERO,
max: Vec2::new(0.0, 1.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the right edge — full height, child width via `offset_min.x`
/// (negative widens the child leftward).
pub const RIGHT: Self = Self {
min: Vec2::new(1.0, 0.0),
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Construct an anchor with explicit corner pair (offsets zero).
pub const fn between(min: Vec2, max: Vec2) -> Self {
Self {
min,
max,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
}
}
/// Add fixed offsets in logical pixels to the resolved corners.
pub const fn with_offsets(mut self, offset_min: Vec2, offset_max: Vec2) -> Self {
self.offset_min = offset_min;
self.offset_max = offset_max;
self
}
}
impl Default for Anchor {
fn default() -> Self {
Self::FILL
}
}
/// Combined style controlling how a widget sizes, spaces, and aligns itself
/// inside its parent's slot.
///
/// `LayoutStyle` is deliberately one flat struct (rather than per-axis or
/// per-mode sub-structs) because every widget needs the same fields and most
/// of them are zero by default. Tests and authors can write
/// `LayoutStyle::default()` and only set the fields they care about.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct LayoutStyle {
/// Horizontal sizing rule.
pub width: Sizing,
/// Vertical sizing rule.
pub height: Sizing,
/// Space *inside* this widget's rect, before children are arranged.
pub padding: Insets,
/// Space *outside* this widget's rect, reserved in the parent's layout
/// before computing leftover space.
pub margin: Insets,
/// Horizontal alignment when this widget's resolved width is smaller than
/// the slot the parent gave it.
pub align_horizontal: Align,
/// Vertical alignment when this widget's resolved height is smaller than
/// the slot the parent gave it.
pub align_vertical: Align,
/// Anchor — only consulted when this widget's parent is an
/// [`AnchorGroup`](super::widget::AnchorGroup); ignored otherwise.
pub anchor: Anchor,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_layout_style_is_fit_content_fill_anchor() {
let s = LayoutStyle::default();
assert_eq!(s.width, Sizing::FitContent);
assert_eq!(s.height, Sizing::FitContent);
assert_eq!(s.padding, Insets::ZERO);
assert_eq!(s.margin, Insets::ZERO);
assert_eq!(s.align_horizontal, Align::Start);
assert_eq!(s.align_vertical, Align::Start);
assert_eq!(s.anchor, Anchor::FILL);
}
#[test]
fn insets_helpers_are_correct() {
let i = Insets::all(4.0);
assert_eq!(i.left, 4.0);
assert_eq!(i.right, 4.0);
assert_eq!(i.top, 4.0);
assert_eq!(i.bottom, 4.0);
assert_eq!(i.horizontal(), 8.0);
assert_eq!(i.vertical(), 8.0);
let s = Insets::symmetric(2.0, 6.0);
assert_eq!(s.horizontal(), 4.0);
assert_eq!(s.vertical(), 12.0);
let scaled = i.scaled(2.0);
assert_eq!(scaled, Insets::all(8.0));
}
#[test]
fn anchor_constants_match_doc_corners() {
// FILL spans the whole parent.
assert_eq!(Anchor::FILL.min, Vec2::ZERO);
assert_eq!(Anchor::FILL.max, Vec2::ONE);
// Each corner pin collapses to a point.
assert_eq!(Anchor::TOP_LEFT.min, Anchor::TOP_LEFT.max);
assert_eq!(Anchor::TOP_RIGHT.min, Vec2::new(1.0, 0.0));
assert_eq!(Anchor::BOTTOM_LEFT.max, Vec2::new(0.0, 1.0));
assert_eq!(Anchor::BOTTOM_RIGHT.min, Vec2::ONE);
// Edge docks span one full axis.
assert_eq!(Anchor::TOP.min, Vec2::ZERO);
assert_eq!(Anchor::TOP.max, Vec2::new(1.0, 0.0));
assert_eq!(Anchor::BOTTOM.min, Vec2::new(0.0, 1.0));
assert_eq!(Anchor::LEFT.max, Vec2::new(0.0, 1.0));
assert_eq!(Anchor::RIGHT.min, Vec2::new(1.0, 0.0));
}
#[test]
fn layout_style_round_trips_through_ron() {
let s = LayoutStyle {
width: Sizing::Grow(2.0),
height: Sizing::Fixed(48.0),
padding: Insets::all(8.0),
margin: Insets::symmetric(4.0, 2.0),
align_horizontal: Align::Center,
align_vertical: Align::End,
anchor: Anchor::TOP_RIGHT.with_offsets(Vec2::new(-100.0, 0.0), Vec2::ZERO),
};
let text = ron::ser::to_string_pretty(&s, ron::ser::PrettyConfig::default()).unwrap();
let decoded: LayoutStyle = ron::de::from_str(&text).unwrap();
assert_eq!(s, decoded);
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Glyph atlas — packs rasterized glyphs into one R8 alpha texture, caches
//! them by (font, glyph, size), and exposes UV regions the renderer draws as
//! textured quads.
//!
//! The atlas **is** the cache: every glyph is rasterized exactly once per
//! `(FontId, GlyphId, size_px)` triple and reused for the rest of the
//! process's lifetime. The performance discussion in the Stage-8 design
//! notes assumes this — a HUD that repaints the same characters every frame
//! never re-rasterizes after warm-up.
//!
//! # Packer choice
//!
//! Piece 3 uses a **shelf packer**: glyphs are arranged in horizontal rows
//! ("shelves") whose height is the height of the first glyph that opened the
//! shelf. Subsequent glyphs either fit horizontally on an existing shelf
//! (height ≤ shelf height) or start a new shelf below. This is the standard
//! choice for monotonically-growing glyph atlases — simple, deterministic,
//! near-optimal density for typically-uniform glyph heights, and easy to
//! grow (later: multi-page atlases) when full.
//!
//! Piece 3 does **not** evict. With a 1024×1024 R8 atlas the typical Western
//! UI uses a single-digit-percent fraction; CJK or many-size scenarios that
//! actually run out are handled by piece-4 follow-ups (multi-page atlases
//! or LRU per page).
use std::collections::HashMap;
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::font::{FontId, FontStore, GlyphId};
/// Cache key for one rasterized glyph.
///
/// `size_px` is rounded to the nearest pixel before being used as the key —
/// distinct 23.4-pixel and 23.6-pixel renderings would otherwise produce
/// different atlas entries despite being visually indistinguishable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphKey {
pub font: FontId,
pub glyph: GlyphId,
pub size_px: u16,
}
impl GlyphKey {
/// Build a key, rounding `size_px` to the nearest pixel.
pub fn new(font: FontId, glyph: GlyphId, size_px: f32) -> Self {
Self {
font,
glyph,
size_px: size_px.round().max(1.0) as u16,
}
}
}
/// One glyph's packed location inside the atlas plus the metrics the
/// renderer needs to position its quad on a baseline.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtlasEntry {
/// Top-left UV (normalized to `[0, 1]`).
pub uv_min: Vec2,
/// Bottom-right UV.
pub uv_max: Vec2,
/// Width / height of the packed region in **pixels**, so the renderer
/// can size the quad without re-querying the atlas dimensions.
pub size_px: Vec2,
/// Offset from the glyph's pen position to the top-left of the quad,
/// in pixels (`bearing.x` left/right, `bearing.y` from the **baseline**;
/// negative `y` means the glyph extends above the baseline).
pub bearing: Vec2,
/// Horizontal advance for the next glyph at this size.
pub advance_px: f32,
}
/// CPU-side glyph atlas — owns the alpha buffer, the packer state, and the
/// `(GlyphKey -> AtlasEntry)` cache.
///
/// A piece-4 GPU follow-up will upload [`pixels`](Self::pixels) into a
/// single R8 texture and re-upload only the dirty region when new glyphs are
/// packed. Piece 3 stays pixel-buffer-only so every test runs headlessly.
#[derive(Debug)]
pub struct GlyphAtlas {
width: u32,
height: u32,
pixels: Vec<u8>,
cache: HashMap<GlyphKey, AtlasEntry>,
packer: ShelfPacker,
dirty: bool,
}
impl GlyphAtlas {
/// Allocate a fresh `width × height` R8 atlas (one byte per pixel,
/// initially zero).
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
pixels: vec![0u8; (width as usize) * (height as usize)],
cache: HashMap::new(),
packer: ShelfPacker::new(width, height),
dirty: false,
}
}
/// `(width, height)` in pixels.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Raw alpha buffer (`width * height` bytes, row-major). The piece-4
/// render pass will upload this into an R8 texture; tests assert on it
/// directly.
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
/// Look up an entry, rasterizing and packing if not yet present.
///
/// Returns `None` if the glyph has no outline (e.g., a space — the
/// shaper still positions it via the font's advance) **or** the atlas
/// has no room for the rasterized bitmap. A space-glyph miss is
/// indistinguishable from a packing failure by signature; in practice
/// the shaper handles both the same way (skip the quad, keep the
/// advance).
pub fn get_or_rasterize(&mut self, key: GlyphKey, fonts: &FontStore) -> Option<AtlasEntry> {
if let Some(entry) = self.cache.get(&key) {
return Some(*entry);
}
let font = fonts.get(key.font)?;
let raster = font.rasterize(key.glyph, key.size_px as f32)?;
let (x, y) = self.packer.pack(raster.width, raster.height)?;
// Blit the alpha mask into the atlas at (x, y).
let aw = self.width as usize;
for row in 0..raster.height as usize {
let src_start = row * raster.width as usize;
let dst_start = (y as usize + row) * aw + x as usize;
self.pixels[dst_start..dst_start + raster.width as usize]
.copy_from_slice(&raster.bitmap[src_start..src_start + raster.width as usize]);
}
self.dirty = true;
let w = self.width as f32;
let h = self.height as f32;
let entry = AtlasEntry {
uv_min: Vec2::new(x as f32 / w, y as f32 / h),
uv_max: Vec2::new(
(x + raster.width) as f32 / w,
(y + raster.height) as f32 / h,
),
size_px: Vec2::new(raster.width as f32, raster.height as f32),
bearing: Vec2::new(raster.bearing_x, raster.bearing_y),
advance_px: raster.advance_x,
};
self.cache.insert(key, entry);
Some(entry)
}
/// Borrow an entry that's already cached, without triggering
/// rasterization. Useful when the renderer wants to draw only glyphs the
/// atlas already knows.
pub fn get(&self, key: &GlyphKey) -> Option<&AtlasEntry> {
self.cache.get(key)
}
/// Number of cached glyphs.
pub fn len(&self) -> usize {
self.cache.len()
}
/// `true` if no glyphs are cached.
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
/// `true` if [`get_or_rasterize`](Self::get_or_rasterize) added at least
/// one glyph since the last [`clear_dirty`](Self::clear_dirty). The
/// piece-4 render pass checks this before re-uploading the texture.
pub fn dirty(&self) -> bool {
self.dirty
}
/// Clear the dirty flag. Call after uploading the texture.
pub fn clear_dirty(&mut self) {
self.dirty = false;
}
}
// ---------- shelf packer ----------
#[derive(Debug)]
struct ShelfPacker {
width: u32,
height: u32,
shelves: Vec<Shelf>,
next_y: u32,
}
#[derive(Debug)]
struct Shelf {
y: u32,
height: u32,
cursor_x: u32,
}
impl ShelfPacker {
fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
shelves: Vec::new(),
next_y: 0,
}
}
fn pack(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
if w > self.width || h > self.height {
return None;
}
// Prefer the tightest-fitting existing shelf that still has
// horizontal room — keeps shelf heights stable and packs short
// glyphs against short glyphs.
let mut best: Option<usize> = None;
let mut best_waste = u32::MAX;
for (i, shelf) in self.shelves.iter().enumerate() {
if shelf.cursor_x + w <= self.width && h <= shelf.height {
let waste = shelf.height - h;
if waste < best_waste {
best = Some(i);
best_waste = waste;
}
}
}
if let Some(i) = best {
let shelf = &mut self.shelves[i];
let x = shelf.cursor_x;
let y = shelf.y;
shelf.cursor_x += w;
return Some((x, y));
}
// No existing shelf fits — open a new one at `next_y` if there's
// vertical room.
if self.next_y + h > self.height {
return None;
}
let y = self.next_y;
self.next_y += h;
self.shelves.push(Shelf {
y,
height: h,
cursor_x: w,
});
Some((0, y))
}
}
#[cfg(test)]
mod tests {
use super::super::font::try_load_system_font;
use super::*;
use crate::ui::visual::FontRef;
#[test]
fn key_rounds_size_to_nearest_pixel() {
let k1 = GlyphKey::new(FontId(0), GlyphId(1), 23.4);
let k2 = GlyphKey::new(FontId(0), GlyphId(1), 23.6);
assert_eq!(k1.size_px, 23);
assert_eq!(k2.size_px, 24);
assert_ne!(k1, k2);
}
#[test]
fn key_clamps_sub_pixel_size_to_one() {
// A 0.4-pixel font would otherwise round to zero, producing a useless
// key. The packer requires width ≥ 1.
let k = GlyphKey::new(FontId(0), GlyphId(1), 0.4);
assert_eq!(k.size_px, 1);
}
#[test]
fn shelf_packer_fits_glyphs_in_order() {
let mut p = ShelfPacker::new(64, 64);
// First glyph opens a shelf at y=0 with height 10.
assert_eq!(p.pack(20, 10), Some((0, 0)));
// Second glyph fits on the same shelf — same y, advanced cursor.
assert_eq!(p.pack(20, 10), Some((20, 0)));
// Third glyph: doesn't fit horizontally on shelf 0; opens shelf 1
// at y=10.
assert_eq!(p.pack(40, 8), Some((0, 10)));
// Tall glyph that fits horizontally on neither existing shelf opens
// shelf 2 at y=18.
assert_eq!(p.pack(64, 20), Some((0, 18)));
}
#[test]
fn shelf_packer_prefers_tight_fit_among_existing_shelves() {
let mut p = ShelfPacker::new(64, 64);
// Open shelf 0 at y=0 with height 20, occupying width 50.
assert_eq!(p.pack(50, 20), Some((0, 0)));
// A 50-wide 8-tall glyph won't fit horizontally on shelf 0
// (50 + 50 = 100 > 64) — that forces shelf 1 open at y=20 with
// height 8.
assert_eq!(p.pack(50, 8), Some((0, 20)));
// Now pack a 10×8 glyph: shelf 0 (waste 12) and shelf 1 (waste 0)
// both fit horizontally, so the tight-fit shelf 1 wins.
assert_eq!(p.pack(10, 8), Some((50, 20)));
}
#[test]
fn shelf_packer_rejects_overflow() {
let mut p = ShelfPacker::new(32, 32);
// First fills almost all the vertical room.
assert_eq!(p.pack(32, 30), Some((0, 0)));
// 4-tall glyph won't fit vertically.
assert_eq!(p.pack(8, 4), None);
// Anything wider than the atlas is also rejected.
let mut p2 = ShelfPacker::new(32, 32);
assert_eq!(p2.pack(40, 4), None);
}
#[test]
fn empty_atlas_has_no_dirty_no_entries() {
let atlas = GlyphAtlas::new(64, 64);
assert_eq!(atlas.size(), (64, 64));
assert!(!atlas.dirty());
assert!(atlas.is_empty());
assert!(atlas.pixels().iter().all(|&p| p == 0));
}
#[test]
fn dirty_flag_lifecycle() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let mut atlas = GlyphAtlas::new(256, 256);
assert!(!atlas.dirty());
let glyph = store.get(id).unwrap().glyph_id('A');
atlas
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
.unwrap();
assert!(atlas.dirty());
atlas.clear_dirty();
assert!(!atlas.dirty());
// Second lookup of the same key is a cache hit — no rasterization,
// no new dirty.
atlas
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
.unwrap();
assert!(!atlas.dirty());
}
#[test]
fn distinct_glyphs_get_distinct_regions() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert_with_descriptor(FontRef::regular("System"), font);
let mut atlas = GlyphAtlas::new(512, 512);
let a = store.get(id).unwrap().glyph_id('A');
let b = store.get(id).unwrap().glyph_id('B');
let e_a = atlas
.get_or_rasterize(GlyphKey::new(id, a, 24.0), &store)
.unwrap();
let e_b = atlas
.get_or_rasterize(GlyphKey::new(id, b, 24.0), &store)
.unwrap();
// Different glyphs → different UV rects.
assert_ne!(e_a.uv_min, e_b.uv_min);
// UV rects stay inside `[0, 1]`.
assert!(e_a.uv_min.x >= 0.0 && e_a.uv_max.x <= 1.0);
assert!(e_a.uv_min.y >= 0.0 && e_a.uv_max.y <= 1.0);
assert_eq!(atlas.len(), 2);
}
#[test]
fn space_glyph_returns_none_but_does_not_corrupt_atlas() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let space = store.get(id).unwrap().glyph_id(' ');
let mut atlas = GlyphAtlas::new(128, 128);
assert!(atlas
.get_or_rasterize(GlyphKey::new(id, space, 24.0), &store)
.is_none());
assert!(atlas.is_empty());
assert!(!atlas.dirty());
}
#[test]
fn atlas_pixels_match_rasterized_bitmap_at_packed_region() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let mut atlas = GlyphAtlas::new(128, 128);
let glyph = store.get(id).unwrap().glyph_id('A');
let entry = atlas
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
.unwrap();
// Convert the entry's UV back to a pixel rect and verify *some*
// pixel inside it is opaque (i.e., the blit actually happened).
let x = (entry.uv_min.x * 128.0).round() as usize;
let y = (entry.uv_min.y * 128.0).round() as usize;
let w = entry.size_px.x as usize;
let h = entry.size_px.y as usize;
let mut had_opaque = false;
for row in 0..h {
for col in 0..w {
if atlas.pixels()[(y + row) * 128 + (x + col)] > 200 {
had_opaque = true;
}
}
}
assert!(had_opaque, "blitted region should contain opaque pixels");
}
}
+429
View File
@@ -0,0 +1,429 @@
//! Font loading and per-glyph metrics — thin wrapper over [`ab_glyph::FontVec`].
//!
//! The text system stays a layer above the font crate so it can swap
//! rasterizers later (an SDF generator, a different parser) without churning
//! the public Stage-8 API. Every text query a [`super::shape::shape`] or
//! [`super::atlas::GlyphAtlas`] call needs goes through [`Font`]'s methods —
//! `ab_glyph` is never visible to consumers of the engine.
use std::collections::HashMap;
use std::path::Path;
use ab_glyph::{Font as AbFont, FontVec, PxScale, ScaleFont};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::super::visual::FontRef;
/// Errors returned from font loading.
#[derive(Debug, Error)]
pub enum FontError {
/// Reading the font file from disk failed.
#[error("font file read failed: {0}")]
Io(#[from] std::io::Error),
/// The bytes were not a valid TTF / OTF font.
#[error("not a valid TTF/OTF font")]
InvalidFont,
}
/// Stable, opaque identifier for a font registered in a [`FontStore`].
///
/// Held in [`GlyphKey`](super::atlas::GlyphKey)s in the atlas and in
/// [`TextStyle`](super::shape::TextStyle)s passed to the shaper, so a font's
/// id never changes once registered. `Copy` + `Hash` so it indexes hash maps
/// cheaply.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FontId(pub u32);
/// One loaded font — a parsed TTF/OTF that can report metrics and rasterize
/// individual glyphs.
pub struct Font {
inner: FontVec,
}
impl std::fmt::Debug for Font {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Font").finish_non_exhaustive()
}
}
/// Result of rasterizing one glyph at a specific pixel size — the alpha mask
/// plus enough metrics to position it on a baseline.
#[derive(Debug, Clone, PartialEq)]
pub struct RasterizedGlyph {
/// Width of the alpha mask in pixels.
pub width: u32,
/// Height of the alpha mask in pixels.
pub height: u32,
/// X offset from the glyph's pen position to the mask's left edge.
pub bearing_x: f32,
/// Y offset from the glyph's baseline to the mask's top edge (negative
/// for glyphs that extend above the baseline, which is most of them).
pub bearing_y: f32,
/// How far to advance the pen along the baseline before the next glyph.
pub advance_x: f32,
/// Row-major alpha bytes (`width * height` bytes, `0 = transparent`,
/// `255 = opaque`).
pub bitmap: Vec<u8>,
}
impl Font {
/// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`].
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
FontVec::try_from_vec(bytes)
.map(|inner| Self { inner })
.map_err(|_| FontError::InvalidFont)
}
/// Load and parse a TTF/OTF file from disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FontError> {
let bytes = std::fs::read(path.as_ref())?;
Self::from_bytes(bytes)
}
/// The glyph id for a `char`. Returns the font's `notdef` glyph (id `0`)
/// for characters the font does not contain — same behavior as
/// `ab_glyph`.
pub fn glyph_id(&self, ch: char) -> GlyphId {
GlyphId(self.inner.glyph_id(ch).0)
}
/// Horizontal advance for the next glyph at `size_px` logical pixels.
pub fn h_advance_px(&self, glyph: GlyphId, size_px: f32) -> f32 {
self.inner
.as_scaled(PxScale::from(size_px))
.h_advance(ab_glyph::GlyphId(glyph.0))
}
/// Ascender height in pixels at the given size.
pub fn ascent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).ascent()
}
/// Descender depth in pixels at the given size. Negative for fonts where
/// the descender sits below the baseline (the common case).
pub fn descent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).descent()
}
/// Line gap in pixels — extra leading the font recommends between lines.
pub fn line_gap_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).line_gap()
}
/// Total recommended line height at `size_px` (ascent descent +
/// line_gap). Multiplied by `TextStyle`'s line-height factor by the
/// shaper.
pub fn line_height_px(&self, size_px: f32) -> f32 {
let scaled = self.inner.as_scaled(PxScale::from(size_px));
scaled.ascent() - scaled.descent() + scaled.line_gap()
}
/// Rasterize a single glyph to an alpha bitmap. Returns `None` for
/// glyphs with no outline (e.g., the space character) — the caller still
/// gets the advance via [`Font::h_advance_px`] and should treat the
/// glyph as zero-area.
pub fn rasterize(&self, glyph: GlyphId, size_px: f32) -> Option<RasterizedGlyph> {
let scale = PxScale::from(size_px);
let scaled = self.inner.as_scaled(scale);
let advance_x = scaled.h_advance(ab_glyph::GlyphId(glyph.0));
let mut positioned = ab_glyph::GlyphId(glyph.0).with_scale(scale);
positioned.position = ab_glyph::point(0.0, 0.0);
let outlined = self.inner.outline_glyph(positioned)?;
let bounds = outlined.px_bounds();
let width = bounds.width().ceil().max(1.0) as u32;
let height = bounds.height().ceil().max(1.0) as u32;
let mut bitmap = vec![0u8; (width as usize) * (height as usize)];
outlined.draw(|x, y, coverage| {
if x < width && y < height {
let idx = (y as usize) * (width as usize) + (x as usize);
bitmap[idx] = (coverage * 255.0).round().clamp(0.0, 255.0) as u8;
}
});
Some(RasterizedGlyph {
width,
height,
bearing_x: bounds.min.x,
bearing_y: bounds.min.y,
advance_x,
bitmap,
})
}
}
/// [`AssetLoader`](crate::asset::AssetLoader) for TTF/OTF fonts.
///
/// Registered by default on every [`AssetServer`](crate::asset::AssetServer), so
/// a font file under a project's `assets/fonts/` can be loaded by path and an
/// [`AssetRef<Font>`](crate::asset::AssetRef) resolved to a [`Handle<Font>`](crate::asset::Handle)
/// — the link that lets the UI canvas pick a font asset and the runtime draw with it.
pub struct FontLoader;
impl crate::asset::AssetLoader for FontLoader {
type Asset = Font;
fn extensions(&self) -> &'static [&'static str] {
&["ttf", "otf"]
}
fn load(&self, path: &Path) -> Result<Font, crate::asset::AssetError> {
Font::from_path(path).map_err(|err| crate::asset::AssetError::Load {
path: path.to_path_buf(),
message: err.to_string(),
})
}
}
/// Opaque per-font glyph index. Mirrors `ab_glyph::GlyphId` but is the only
/// glyph type exposed by the engine, so consumers do not need an `ab_glyph`
/// dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphId(pub u16);
/// Registry of loaded fonts, indexed by [`FontId`] and (optionally) by
/// [`FontRef`] descriptor.
///
/// Why a descriptor index: piece-2 [`Theme`](super::super::theme::Theme)s
/// store fonts by family + weight + italic (`FontRef`), not by raw bytes.
/// `FontStore::resolve(&font_ref)` turns the descriptor into a [`FontId`] the
/// shaper can use, so a theme like `{ font: Some(FontRef::bold("Inter")) }`
/// works end-to-end as soon as the matching face has been registered.
#[derive(Default)]
pub struct FontStore {
fonts: Vec<Font>,
by_descriptor: HashMap<FontRef, FontId>,
}
impl std::fmt::Debug for FontStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FontStore")
.field("len", &self.fonts.len())
.field("descriptors", &self.by_descriptor.len())
.finish()
}
}
impl FontStore {
/// Create an empty store.
pub fn new() -> Self {
Self::default()
}
/// Register a font with no descriptor — accessible only by its returned
/// [`FontId`]. Useful for one-off uses where the font isn't part of a
/// theme cascade.
pub fn insert(&mut self, font: Font) -> FontId {
let id = FontId(self.fonts.len() as u32);
self.fonts.push(font);
id
}
/// Register a font and associate it with a descriptor.
///
/// Re-registering the same descriptor replaces the previous association
/// but does not free the previous [`FontId`] — both ids continue to
/// reference the now-distinct font. This matches Stage-7 `ActionMap`
/// re-registration semantics: ids are stable, names can be remapped.
pub fn insert_with_descriptor(&mut self, descriptor: FontRef, font: Font) -> FontId {
let id = self.insert(font);
self.by_descriptor.insert(descriptor, id);
id
}
/// Look up a font by `FontId`.
pub fn get(&self, id: FontId) -> Option<&Font> {
self.fonts.get(id.0 as usize)
}
/// Resolve a [`FontRef`] descriptor (piece-2 theme value) to a
/// [`FontId`], if the matching face has been registered.
pub fn resolve(&self, descriptor: &FontRef) -> Option<FontId> {
self.by_descriptor.get(descriptor).copied()
}
/// Number of registered fonts.
pub fn len(&self) -> usize {
self.fonts.len()
}
/// `true` if no fonts are registered.
pub fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
}
/// Common system paths a Linux-style host is likely to have a sans-serif
/// TTF at. Used by tests (and the eventual editor "no theme font set" path)
/// to find *some* font without bundling one.
///
/// Returned in priority order; the first existing path is the one to try.
/// Empty on hosts the search doesn't know about — the caller must handle
/// "no candidate found" gracefully.
pub fn common_system_font_paths() -> &'static [&'static str] {
&[
// Linux distributions:
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
// macOS:
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
}
/// Try to load a sans-serif font from a well-known system path. Returns
/// `None` (and prints `SKIP:`) if no candidate exists — the same pattern
/// the Stage-4 GPU tests use for "no adapter".
///
/// Test-only helper shared between the `font`, `atlas`, and `shape` modules
/// so the same "skip when no system font" branch isn't duplicated.
#[cfg(test)]
pub(crate) fn try_load_system_font() -> Option<Font> {
for path in common_system_font_paths() {
if Path::new(path).exists() {
match Font::from_path(path) {
Ok(font) => return Some(font),
Err(err) => {
eprintln!("SKIP-candidate: {path} present but failed to load: {err}");
}
}
}
}
eprintln!("SKIP: no system font available at any common Linux/macOS path");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
let err = Font::from_bytes(vec![0u8; 32]).unwrap_err();
assert!(matches!(err, FontError::InvalidFont));
}
#[test]
fn missing_file_returns_io_error() {
let err = Font::from_path("/nonexistent/font.ttf").unwrap_err();
assert!(matches!(err, FontError::Io(_)));
}
#[test]
fn font_loader_loads_through_the_asset_server() {
use crate::asset::{AssetRef, AssetServer, AssetUid};
// Find a real font file on disk; skip cleanly if the host has none.
let Some(path) = common_system_font_paths()
.iter()
.map(std::path::Path::new)
.find(|p| p.exists())
else {
eprintln!("SKIP: no system font path available");
return;
};
// The default-registered FontLoader makes `.ttf`/`.otf` loadable.
let server = AssetServer::new();
let handle = server.load::<Font>(path);
assert!(handle.is_loaded(), "font should load: {:?}", handle.error());
// An asset reference to a hypothetical uid resolves to a handle when the
// database hands back this path (proven in asset::database tests); here
// we just confirm the loaded Font is usable.
assert!(handle.get().unwrap().h_advance_px(GlyphId(0), 16.0) >= 0.0);
// AssetRef<Font> is constructible (the field type the UI canvas uses).
let _ = AssetRef::<Font>::new(AssetUid(1));
}
#[test]
fn store_assigns_distinct_ids() {
let Some(a) = try_load_system_font() else {
return;
};
let Some(b) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id_a = store.insert(a);
let id_b = store.insert(b);
assert_ne!(id_a, id_b);
assert_eq!(store.len(), 2);
assert!(store.get(id_a).is_some());
assert!(store.get(id_b).is_some());
assert!(store.get(FontId(99)).is_none());
}
#[test]
fn descriptor_resolves_to_registered_font() {
let Some(font) = try_load_system_font() else {
return;
};
let descriptor = FontRef::regular("System");
let mut store = FontStore::new();
let id = store.insert_with_descriptor(descriptor.clone(), font);
assert_eq!(store.resolve(&descriptor), Some(id));
// A different descriptor with no associated font is None.
assert_eq!(store.resolve(&FontRef::bold("System")), None);
}
#[test]
fn metrics_are_finite_and_non_zero() {
let Some(font) = try_load_system_font() else {
return;
};
let advance = font.h_advance_px(font.glyph_id('A'), 24.0);
assert!(advance.is_finite());
assert!(advance > 0.0);
let ascent = font.ascent_px(24.0);
let descent = font.descent_px(24.0);
assert!(ascent > 0.0);
// ab_glyph's `descent` is negative for descenders below the baseline.
assert!(descent <= 0.0);
assert!(font.line_height_px(24.0) > 0.0);
}
#[test]
fn rasterize_produces_bitmap_for_solid_glyph() {
let Some(font) = try_load_system_font() else {
return;
};
let raster = font
.rasterize(font.glyph_id('A'), 24.0)
.expect("'A' outlines");
assert!(raster.width > 0 && raster.height > 0);
assert_eq!(
raster.bitmap.len(),
(raster.width as usize) * (raster.height as usize)
);
// A capital A at 24px should have at least one fully-opaque pixel
// near its central stroke.
assert!(raster.bitmap.iter().any(|&p| p > 200));
// And some transparent pixels (it's not a solid square).
assert!(raster.bitmap.iter().any(|&p| p < 10));
}
#[test]
fn rasterize_space_returns_none_but_advance_works() {
let Some(font) = try_load_system_font() else {
return;
};
let space = font.glyph_id(' ');
// Space has no outline — rasterize returns None.
assert!(font.rasterize(space, 24.0).is_none());
// But the advance is still positive so the shaper can lay it out.
assert!(font.h_advance_px(space, 24.0) > 0.0);
}
#[test]
fn common_system_font_paths_returns_some_candidates() {
let paths = common_system_font_paths();
assert!(!paths.is_empty());
// Every entry should be an absolute path so the existence check is
// unambiguous on the host.
for p in paths {
assert!(p.starts_with('/'), "{p:?} should be an absolute path");
}
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Text shaping + glyph atlas — piece 3 of the Stage-8 in-game UI system.
//!
//! Three sub-modules cooperate:
//!
//! - [`font`] wraps `ab_glyph::FontVec` behind an engine-owned [`Font`] /
//! [`FontStore`] surface so consumers never see the font crate directly.
//! Adds descriptor-based lookup keyed by the piece-2
//! [`FontRef`](super::visual::FontRef), so a theme's `font: Some(...)`
//! resolves to a [`FontId`] the shaper can use.
//! - [`atlas`] packs rasterized glyphs into one R8 alpha texture via a
//! shelf packer and caches them by [`GlyphKey`]. The atlas **is** the
//! cache — the chosen library never re-rasterizes a glyph that's already
//! been packed, which is why this stage's choice between ab_glyph and
//! fontdue is a one-time-startup decision, not a per-frame one.
//! - [`shape`] turns a sequence of [`TextRun`]s into positioned
//! [`ShapedGlyph`]s with line wrapping, alignment, multi-font runs, and
//! DPI scaling. Pure CPU; never touches the atlas. The renderer
//! (piece 4) walks the [`ShapedText`] output and queries the atlas per
//! glyph to emit textured quads.
//!
//! # End-to-end shape → atlas
//!
//! ```no_run
//! use oxide_engine::ui::text::{
//! shape, FontStore, GlyphAtlas, ShapeParams, ShapedText, TextStyle,
//! };
//! # use oxide_engine::ui::text::Font;
//! # fn load_font() -> Font { todo!() }
//!
//! let mut fonts = FontStore::new();
//! let id = fonts.insert(load_font());
//! let style = TextStyle { font: id, size_px: 16.0 };
//! let shaped: ShapedText = shape("Hello world", style, &ShapeParams::default(), &fonts);
//!
//! let mut atlas = GlyphAtlas::new(1024, 1024);
//! for line in &shaped.lines {
//! for glyph in &line.glyphs {
//! // get_or_rasterize returns None for glyphs with no outline (e.g.
//! // the space character). Real renderers skip emitting a quad.
//! if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
//! let _quad_top_left = glyph.position + entry.bearing;
//! let _quad_size = entry.size_px;
//! }
//! }
//! }
//! ```
pub mod atlas;
pub mod font;
pub mod shape;
pub use atlas::{AtlasEntry, GlyphAtlas, GlyphKey};
pub use font::{
common_system_font_paths, Font, FontError, FontId, FontLoader, FontStore, GlyphId,
RasterizedGlyph,
};
pub use shape::{
shape, shape_runs, ShapeParams, ShapedGlyph, ShapedLine, ShapedText, TextAlign, TextRun,
TextStyle,
};
+726
View File
@@ -0,0 +1,726 @@
//! Text shaping — turns a sequence of [`TextRun`]s into positioned glyphs,
//! laid out on baselines, wrapped to a width, and aligned.
//!
//! The shaper does **not** rasterize: it only consults [`Font`](super::font::Font)
//! metrics (ascender, descender, advance width). Each output [`ShapedGlyph`]
//! carries a [`GlyphKey`] the renderer (piece 4) feeds into the atlas to
//! resolve to a textured quad. This split keeps the shaper purely
//! deterministic and CPU-cheap — every test in this module runs without a
//! GPU and most without a font.
//!
//! # Algorithm
//!
//! 1. **Tokenize** each run into items: a `Word` (maximal run of non-
//! whitespace), a `Whitespace` stretch, or a `Break` (`\n`). Each
//! word/whitespace item caches its own width, computed once from the
//! font's per-glyph advance.
//! 2. **Greedy line break**: keep adding items to the current line; on a
//! word that would overflow `max_width`, flush the line and start a new
//! one. Pending inter-word whitespace at the wrap point is **discarded**
//! (it was the gap between lines, not part of either line); leading
//! whitespace on a wrapped line is dropped for the same reason. `\n`
//! forces a flush regardless of width.
//! 3. **Position**: for each line, find the line's `max_ascent` (across the
//! fonts used on it) — that's the baseline offset from the line's top
//! edge — then walk items left-to-right, emitting `ShapedGlyph`s at
//! `(pen_x, baseline_y)` and advancing `pen_x` by each glyph's advance.
//! 4. **Align**: per line, shift glyphs by `align_offset(max_width
//! line_width)` — Left/Center/Right. Without a `max_width`, alignment
//! is degenerate (everything is left-aligned).
//!
//! # Multi-font runs
//!
//! Lines may mix items from different runs (and therefore different fonts).
//! Line metrics (ascent, descent, line height) are taken from the *largest*
//! contribution among the line's items. This is the CSS behavior: a small
//! superscript run on the same line as body text doesn't collapse the
//! baseline.
//!
//! # Limitations (deliberate, scoped to piece 3)
//!
//! - One glyph per `char` (no ligatures, no combining marks, no shaping).
//! ab_glyph does not shape; full Unicode shaping is a `rustybuzz` /
//! `harfbuzz` follow-up.
//! - No BiDi or RTL — text flows left-to-right.
//! - No hyphenation or character-level fallback inside an overflowing word.
//! - Whitespace is ASCII (` `, `\t`, `\r`). `\t` and `\r` are treated as
//! regular spaces.
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::atlas::GlyphKey;
use super::font::{FontId, FontStore};
/// Per-run style — which font and what point size.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TextStyle {
pub font: FontId,
/// Logical font size in pixels. Multiplied by [`ShapeParams::scale`] at
/// shape time, so the same `TextStyle` produces correctly-sized output
/// at 1×, 2×, or any other DPI factor.
pub size_px: f32,
}
/// One run of text with a single [`TextStyle`].
///
/// `shape` takes a single run; `shape_runs` takes many for mixed styles
/// (different fonts/sizes/etc. on the same line).
#[derive(Debug, Clone, Copy)]
pub struct TextRun<'a> {
pub text: &'a str,
pub style: TextStyle,
}
/// Horizontal alignment of each line within `max_width`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TextAlign {
#[default]
Left,
Center,
Right,
}
/// Parameters that apply to the whole shape call: wrapping width, alignment,
/// line-height factor, and the DPI scale factor.
#[derive(Debug, Clone, Copy)]
pub struct ShapeParams {
/// Maximum line width in **post-scale** pixels. `None` disables
/// wrapping (and makes alignment a no-op).
pub max_width: Option<f32>,
/// Horizontal alignment within `max_width`.
pub align: TextAlign,
/// Multiplier applied to each line's natural line height. `1.0` is the
/// font's own recommendation; `1.4` is a comfortable reading default.
pub line_height: f32,
/// DPI scale factor — multiplies every logical `size_px` from the
/// runs. Same role as [`super::super::layout::layout`]'s `scale`.
pub scale: f32,
}
impl Default for ShapeParams {
fn default() -> Self {
Self {
max_width: None,
align: TextAlign::Left,
line_height: 1.0,
scale: 1.0,
}
}
}
/// One positioned glyph in the shaped output.
///
/// `position` is the **pen position at the baseline** — the renderer adds
/// the atlas's per-glyph bearing to convert it into the top-left of the
/// glyph quad. Keeping it at the baseline (rather than at the top-left) is
/// what makes hit testing and caret positioning straightforward in pieces
/// 56.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShapedGlyph {
pub key: GlyphKey,
pub position: Vec2,
}
/// One shaped line — the glyphs, the line's content width (trailing
/// whitespace excluded), and the line's baseline / total height.
#[derive(Debug, Clone, PartialEq)]
pub struct ShapedLine {
pub glyphs: Vec<ShapedGlyph>,
pub width: f32,
pub baseline_y: f32,
pub line_height: f32,
}
/// Full shaped output — `lines` in vertical order and the overall bounding
/// box `size`. `size.x` is the widest line's width (not `max_width`);
/// `size.y` is the sum of line heights, which equals the height of the
/// rectangle the text fits in.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShapedText {
pub lines: Vec<ShapedLine>,
pub size: Vec2,
}
/// Shape a single run of text. Convenience wrapper around [`shape_runs`].
pub fn shape(text: &str, style: TextStyle, params: &ShapeParams, fonts: &FontStore) -> ShapedText {
shape_runs(&[TextRun { text, style }], params, fonts)
}
/// Shape one or more runs into a single output. Items from different runs
/// share lines and share alignment, just as if they were one continuous
/// string with mixed styles.
pub fn shape_runs(runs: &[TextRun], params: &ShapeParams, fonts: &FontStore) -> ShapedText {
let mut items: Vec<Item> = Vec::new();
for run in runs {
tokenize_run(run, params.scale, fonts, &mut items);
}
let raw_lines = break_lines(items, params.max_width);
position_lines(raw_lines, params, fonts)
}
// ---------- internals ----------
#[derive(Debug, Clone)]
enum Item {
Word {
font: FontId,
size_px: f32,
width: f32,
// (char, glyph id, advance) — kept so the positioner doesn't have to
// re-walk the source string.
glyphs: Vec<GlyphAdvance>,
},
Whitespace {
font: FontId,
size_px: f32,
width: f32,
},
Break,
}
#[derive(Debug, Clone, Copy)]
struct GlyphAdvance {
glyph: super::font::GlyphId,
advance: f32,
}
impl Item {
fn width(&self) -> f32 {
match self {
Item::Word { width, .. } | Item::Whitespace { width, .. } => *width,
Item::Break => 0.0,
}
}
fn font_size(&self) -> Option<(FontId, f32)> {
match self {
Item::Word { font, size_px, .. } | Item::Whitespace { font, size_px, .. } => {
Some((*font, *size_px))
}
Item::Break => None,
}
}
fn is_whitespace(&self) -> bool {
matches!(self, Item::Whitespace { .. })
}
}
fn is_break(c: char) -> bool {
c == '\n'
}
fn is_space_like(c: char) -> bool {
matches!(c, ' ' | '\t' | '\r')
}
fn tokenize_run(run: &TextRun, scale: f32, fonts: &FontStore, out: &mut Vec<Item>) {
let style = run.style;
let size_px = style.size_px * scale;
let Some(font) = fonts.get(style.font) else {
// Unknown font id — skip the run rather than panicking. Tests in
// piece 4 will catch missing fonts before rendering; for piece 3
// we want shape to remain a total function.
return;
};
let mut buf_word: Vec<GlyphAdvance> = Vec::new();
let mut buf_word_width: f32 = 0.0;
let mut buf_ws_width: f32 = 0.0;
let mut state = TokState::Empty;
for c in run.text.chars() {
if is_break(c) {
flush_buffers(
&mut state,
&mut buf_word,
&mut buf_word_width,
&mut buf_ws_width,
style.font,
size_px,
out,
);
out.push(Item::Break);
continue;
}
if is_space_like(c) {
if matches!(state, TokState::Word) {
out.push(Item::Word {
font: style.font,
size_px,
width: buf_word_width,
glyphs: std::mem::take(&mut buf_word),
});
buf_word_width = 0.0;
}
state = TokState::Whitespace;
let glyph = font.glyph_id(' ');
buf_ws_width += font.h_advance_px(glyph, size_px);
continue;
}
// Non-whitespace.
if matches!(state, TokState::Whitespace) {
out.push(Item::Whitespace {
font: style.font,
size_px,
width: buf_ws_width,
});
buf_ws_width = 0.0;
}
state = TokState::Word;
let glyph = font.glyph_id(c);
let advance = font.h_advance_px(glyph, size_px);
buf_word.push(GlyphAdvance { glyph, advance });
buf_word_width += advance;
}
flush_buffers(
&mut state,
&mut buf_word,
&mut buf_word_width,
&mut buf_ws_width,
style.font,
size_px,
out,
);
}
#[derive(PartialEq)]
enum TokState {
Empty,
Word,
Whitespace,
}
fn flush_buffers(
state: &mut TokState,
word: &mut Vec<GlyphAdvance>,
word_width: &mut f32,
ws_width: &mut f32,
font: FontId,
size_px: f32,
out: &mut Vec<Item>,
) {
match state {
TokState::Word => {
out.push(Item::Word {
font,
size_px,
width: *word_width,
glyphs: std::mem::take(word),
});
*word_width = 0.0;
}
TokState::Whitespace => {
out.push(Item::Whitespace {
font,
size_px,
width: *ws_width,
});
*ws_width = 0.0;
}
TokState::Empty => {}
}
*state = TokState::Empty;
}
fn break_lines(items: Vec<Item>, max_width: Option<f32>) -> Vec<Vec<Item>> {
let mut raw_lines: Vec<Vec<Item>> = Vec::new();
let mut current: Vec<Item> = Vec::new();
let mut current_width: f32 = 0.0;
let mut pending_ws: Vec<Item> = Vec::new();
let mut pending_ws_width: f32 = 0.0;
for item in items {
match item {
Item::Break => {
raw_lines.push(std::mem::take(&mut current));
current_width = 0.0;
pending_ws.clear();
pending_ws_width = 0.0;
}
Item::Whitespace { width, .. } => {
pending_ws_width += width;
pending_ws.push(item);
}
Item::Word { width, .. } => {
let fits = match max_width {
Some(max) => {
current.is_empty() || current_width + pending_ws_width + width <= max
}
None => true,
};
if fits {
current.append(&mut pending_ws);
current_width += pending_ws_width;
current_width += width;
current.push(item);
} else {
raw_lines.push(std::mem::take(&mut current));
// Leading whitespace on a wrapped line is dropped.
pending_ws.clear();
current_width = width;
current.push(item);
}
pending_ws_width = 0.0;
}
}
}
if !current.is_empty() {
raw_lines.push(current);
}
raw_lines
}
fn position_lines(
raw_lines: Vec<Vec<Item>>,
params: &ShapeParams,
fonts: &FontStore,
) -> ShapedText {
let mut lines: Vec<ShapedLine> = Vec::new();
let mut cursor_y: f32 = 0.0;
let mut widest: f32 = 0.0;
for line_items in raw_lines {
// Line metrics from the largest contributing item.
let mut max_ascent: f32 = 0.0;
let mut min_descent: f32 = 0.0;
let mut max_line_height: f32 = 0.0;
for item in &line_items {
if let Some((font_id, size_px)) = item.font_size() {
if let Some(font) = fonts.get(font_id) {
max_ascent = max_ascent.max(font.ascent_px(size_px));
min_descent = min_descent.min(font.descent_px(size_px));
max_line_height = max_line_height.max(font.line_height_px(size_px));
}
}
}
let _ = min_descent; // descent reserved for vertical-extent queries later
let line_height = max_line_height * params.line_height;
// Trailing whitespace is excluded from line width.
let mut content_width: f32 = 0.0;
let last_non_ws = line_items
.iter()
.enumerate()
.rev()
.find(|(_, it)| !it.is_whitespace())
.map(|(i, _)| i);
if let Some(end) = last_non_ws {
for it in &line_items[..=end] {
content_width += it.width();
}
}
// Horizontal alignment offset.
let align_pad = match params.max_width {
Some(max) => {
let extra = (max - content_width).max(0.0);
match params.align {
TextAlign::Left => 0.0,
TextAlign::Center => extra * 0.5,
TextAlign::Right => extra,
}
}
None => 0.0,
};
let baseline_y = cursor_y + max_ascent;
let mut pen_x = align_pad;
let mut glyphs: Vec<ShapedGlyph> = Vec::new();
for item in &line_items {
match item {
Item::Word {
font,
size_px,
glyphs: g,
..
} => {
for ga in g {
glyphs.push(ShapedGlyph {
key: GlyphKey::new(*font, ga.glyph, *size_px),
position: Vec2::new(pen_x, baseline_y),
});
pen_x += ga.advance;
}
}
Item::Whitespace { width, .. } => {
pen_x += *width;
}
Item::Break => {}
}
}
lines.push(ShapedLine {
glyphs,
width: content_width,
baseline_y,
line_height,
});
cursor_y += line_height;
widest = widest.max(content_width);
}
ShapedText {
lines,
size: Vec2::new(widest, cursor_y),
}
}
#[cfg(test)]
mod tests {
use super::super::font::try_load_system_font;
use super::*;
fn make_store_and_style(size_px: f32) -> Option<(FontStore, TextStyle)> {
let font = try_load_system_font()?;
let mut store = FontStore::new();
let id = store.insert(font);
Some((store, TextStyle { font: id, size_px }))
}
#[test]
fn empty_text_produces_no_lines() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape("", style, &ShapeParams::default(), &store);
assert!(out.lines.is_empty());
assert_eq!(out.size, Vec2::ZERO);
}
#[test]
fn single_word_emits_one_line_with_correct_glyph_count() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape("Hello", style, &ShapeParams::default(), &store);
assert_eq!(out.lines.len(), 1);
assert_eq!(out.lines[0].glyphs.len(), 5);
// Glyphs are at the same baseline.
let baseline = out.lines[0].baseline_y;
for g in &out.lines[0].glyphs {
assert_eq!(g.position.y, baseline);
}
// x positions are monotonically increasing.
for w in out.lines[0].glyphs.windows(2) {
assert!(w[1].position.x > w[0].position.x);
}
// Line width matches the last glyph's pen-end (advance sum).
assert!(out.lines[0].width > 0.0);
assert!(out.size.x >= out.lines[0].width);
}
#[test]
fn explicit_newline_starts_new_line() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape("a\nb", style, &ShapeParams::default(), &store);
assert_eq!(out.lines.len(), 2);
assert_eq!(out.lines[0].glyphs.len(), 1);
assert_eq!(out.lines[1].glyphs.len(), 1);
// Second baseline is below the first by one line height.
assert!(out.lines[1].baseline_y > out.lines[0].baseline_y);
}
#[test]
fn word_wrap_splits_into_multiple_lines() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
// A line wide enough for "Hello" but not "Hello world".
let one_word_width = shape("Hello", style, &ShapeParams::default(), &store).lines[0].width;
let params = ShapeParams {
max_width: Some(one_word_width + 2.0),
..ShapeParams::default()
};
let out = shape("Hello world", style, &params, &store);
assert_eq!(out.lines.len(), 2);
// First line is just "Hello" (5 glyphs).
assert_eq!(out.lines[0].glyphs.len(), 5);
// Second line is "world" (5 glyphs); leading whitespace dropped.
assert_eq!(out.lines[1].glyphs.len(), 5);
// Second line starts at x = 0 (Left align by default; no leading
// whitespace consumed pen space).
assert_eq!(out.lines[1].glyphs[0].position.x, 0.0);
}
#[test]
fn trailing_whitespace_excluded_from_line_width() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let bare = shape("Hi", style, &ShapeParams::default(), &store);
let trailing = shape("Hi ", style, &ShapeParams::default(), &store);
assert_eq!(bare.lines[0].width, trailing.lines[0].width);
}
#[test]
fn alignment_shifts_glyph_positions_within_max_width() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let left = shape(
"Hi",
style,
&ShapeParams {
max_width: Some(200.0),
align: TextAlign::Left,
..ShapeParams::default()
},
&store,
);
let center = shape(
"Hi",
style,
&ShapeParams {
max_width: Some(200.0),
align: TextAlign::Center,
..ShapeParams::default()
},
&store,
);
let right = shape(
"Hi",
style,
&ShapeParams {
max_width: Some(200.0),
align: TextAlign::Right,
..ShapeParams::default()
},
&store,
);
let l = left.lines[0].glyphs[0].position.x;
let c = center.lines[0].glyphs[0].position.x;
let r = right.lines[0].glyphs[0].position.x;
assert_eq!(l, 0.0);
assert!(c > l && c < r);
// Centered + right cases place the line within `max_width = 200`.
let width = left.lines[0].width;
assert!((c - (200.0 - width) * 0.5).abs() < 0.001);
assert!((r - (200.0 - width)).abs() < 0.001);
}
#[test]
fn dpi_scale_doubles_advance_widths_and_baseline_drop() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let at_1x = shape("Hello", style, &ShapeParams::default(), &store);
let at_2x = shape(
"Hello",
style,
&ShapeParams {
scale: 2.0,
..ShapeParams::default()
},
&store,
);
// Line width at 2× is ~2× at 1×.
let ratio = at_2x.lines[0].width / at_1x.lines[0].width;
assert!((ratio - 2.0).abs() < 0.05, "ratio = {ratio}");
// First glyph's baseline drops at 2× by ~2× the 1× drop.
let baseline_ratio = at_2x.lines[0].baseline_y / at_1x.lines[0].baseline_y;
assert!((baseline_ratio - 2.0).abs() < 0.1);
}
#[test]
fn line_height_multiplier_increases_vertical_spacing() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let single = shape("a\nb", style, &ShapeParams::default(), &store);
let spaced = shape(
"a\nb",
style,
&ShapeParams {
line_height: 2.0,
..ShapeParams::default()
},
&store,
);
let gap_1 = single.lines[1].baseline_y - single.lines[0].baseline_y;
let gap_2 = spaced.lines[1].baseline_y - spaced.lines[0].baseline_y;
// Doubling the line-height factor roughly doubles inter-baseline
// distance — exact ratio depends on the font's gap fraction.
assert!(
(gap_2 / gap_1 - 2.0).abs() < 0.05,
"gap_2/gap_1 = {}",
gap_2 / gap_1
);
}
#[test]
fn glyph_keys_are_stable_across_calls() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let a = shape("X", style, &ShapeParams::default(), &store);
let b = shape("X", style, &ShapeParams::default(), &store);
assert_eq!(a.lines[0].glyphs[0].key, b.lines[0].glyphs[0].key);
}
#[test]
fn multi_font_run_takes_max_ascent_from_largest_size() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let small = TextStyle {
font: id,
size_px: 12.0,
};
let big = TextStyle {
font: id,
size_px: 32.0,
};
let mixed = shape_runs(
&[
TextRun {
text: "Hi ",
style: small,
},
TextRun {
text: "X",
style: big,
},
],
&ShapeParams::default(),
&store,
);
let small_only = shape("Hi", small, &ShapeParams::default(), &store);
// The big-size baseline must be at least as deep as the small-size
// baseline because the line's ascent is the max of contributions.
assert!(mixed.lines[0].baseline_y >= small_only.lines[0].baseline_y);
}
#[test]
fn unknown_font_id_does_not_panic() {
// No font registered → shape returns no lines instead of panicking.
let store = FontStore::new();
let style = TextStyle {
font: FontId(99),
size_px: 16.0,
};
let out = shape("Hello", style, &ShapeParams::default(), &store);
assert!(out.lines.is_empty());
}
#[test]
fn no_wrap_when_max_width_is_none() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape(
"one two three four five",
style,
&ShapeParams::default(),
&store,
);
assert_eq!(out.lines.len(), 1);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Theme — reusable named [`VisualStyle`]s plus a default fallback.
//!
//! A [`Theme`] is what a project ships to give every UI document a consistent
//! look without hand-styling every widget. The resolution rule is a strict
//! left-to-right cascade:
//!
//! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the
//! project-wide defaults — body text color, border weight, …).
//! 2. If the widget specifies `theme_style: Some("button")` and the theme
//! contains a `"button"` entry, merge that on top.
//! 3. Merge the widget's per-instance `visual` on top.
//!
//! Each merge is field-by-field: a `Some` on the right replaces the field;
//! a `None` keeps what was there. The result is a single [`VisualStyle`]
//! where any field that's still `None` means "the renderer's own hard-coded
//! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass).
//!
//! Why named styles instead of CSS-like selectors: it makes per-widget
//! attribution explicit in the UI document (`theme_style: "button-primary"`)
//! and keeps theme resolution constant-time per widget. CSS selectors and
//! cascading rules are a richer model but their authoring cost dwarfs what
//! Stage-8 game UIs actually need.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::visual::VisualStyle;
/// A named-style theme. Holds a `default` style applied to every widget plus
/// a map of named styles widgets can opt into by their `theme_style` field.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Theme {
/// Project-wide defaults — applied first to every widget before its
/// `theme_style` and per-instance overrides.
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
pub default: VisualStyle,
/// Named style buckets — `theme_style: "button"` on a widget pulls the
/// `"button"` entry here on top of `default`.
///
/// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a
/// deterministic order — important for diff-friendly UI documents and
/// reproducible RON snapshots in tests.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub styles: BTreeMap<String, VisualStyle>,
}
impl Theme {
/// Empty theme — no default fields, no named styles. Every widget under
/// this theme inherits only the renderer's hard-coded fallback.
pub const fn new() -> Self {
Self {
default: VisualStyle::EMPTY,
styles: BTreeMap::new(),
}
}
/// Insert (or replace) a named style. Chainable for builder-style theme
/// construction in tests and examples.
pub fn with_style(mut self, name: impl Into<String>, style: VisualStyle) -> Self {
self.styles.insert(name.into(), style);
self
}
/// Replace the project-wide default style.
pub fn with_default(mut self, default: VisualStyle) -> Self {
self.default = default;
self
}
/// Resolve the effective visual style for a widget that opts into
/// `style_ref` (if any) and provides its own `override_with` per-instance
/// fields.
///
/// Cascade: `self.default` → (`self.styles[style_ref]` if present) →
/// `override_with`. A missing named style is treated as empty (no
/// contribution) rather than an error — UI documents stay valid when a
/// theme is swapped for a smaller one mid-development.
pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle {
let mut resolved = self.default.clone();
if let Some(name) = style_ref {
if let Some(named) = self.styles.get(name) {
resolved = resolved.merged(named);
}
}
resolved.merged(override_with)
}
/// Serialize this theme to a pretty-printed RON string.
pub fn to_ron(&self) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
}
/// Parse a theme from a RON string.
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
ron::de::from_str(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Color;
use crate::ui::visual::{Border, FontRef};
fn theme_with_three_styles() -> 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
},
)
.with_style(
"button-primary",
VisualStyle {
background: Some(Color::rgb(0.2, 0.4, 0.8)),
foreground: Some(Color::WHITE),
..VisualStyle::EMPTY
},
)
.with_style(
"label",
VisualStyle {
foreground: Some(Color::rgb(0.2, 0.2, 0.2)),
..VisualStyle::EMPTY
},
)
}
#[test]
fn resolve_returns_default_for_no_style_or_overrides() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(None, &VisualStyle::EMPTY);
assert_eq!(resolved.foreground, Some(Color::BLACK));
assert_eq!(resolved.background, Some(Color::WHITE));
assert_eq!(resolved.font_size, Some(14.0));
}
#[test]
fn named_style_overrides_default() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY);
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9)));
// Foreground not set on "button" → kept from default.
assert_eq!(resolved.foreground, Some(Color::BLACK));
assert_eq!(resolved.corner_radius, Some(4.0));
}
#[test]
fn per_instance_override_takes_final_precedence() {
let theme = theme_with_three_styles();
let overlay = VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
};
let resolved = theme.resolve(Some("button-primary"), &overlay);
// Per-instance background wins over the named style.
assert_eq!(resolved.background, Some(Color::RED));
// The named style's foreground (WHITE) still beats the default (BLACK).
assert_eq!(resolved.foreground, Some(Color::WHITE));
}
#[test]
fn unknown_named_style_falls_back_to_default() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY);
// Same as resolve(None, &EMPTY).
assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY));
}
#[test]
fn theme_round_trips_through_ron() {
let theme = theme_with_three_styles();
let text = theme.to_ron().unwrap();
let decoded = Theme::from_ron(&text).unwrap();
assert_eq!(theme, decoded);
// Named styles are alphabetised by BTreeMap, so "button" precedes
// "button-primary" precedes "label" in the serialized form.
let button_pos = text.find("\"button\"").unwrap();
let primary_pos = text.find("\"button-primary\"").unwrap();
let label_pos = text.find("\"label\"").unwrap();
assert!(button_pos < primary_pos);
assert!(primary_pos < label_pos);
}
#[test]
fn empty_theme_round_trips_to_empty_ron() {
let empty = Theme::new();
let text = empty.to_ron().unwrap();
let decoded = Theme::from_ron(&text).unwrap();
assert_eq!(empty, decoded);
// The empty theme should not mention either field.
assert!(!text.contains("default:"));
assert!(!text.contains("styles:"));
}
#[test]
fn builder_chaining_inserts_styles_in_order() {
let t = Theme::new()
.with_style("a", VisualStyle::EMPTY)
.with_style("b", VisualStyle::EMPTY);
assert_eq!(t.styles.len(), 2);
assert!(t.styles.contains_key("a"));
assert!(t.styles.contains_key("b"));
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Per-widget typed value — the state interactive widgets carry.
//!
//! Stage 8's UI is data-driven: a slider knows its current position, a
//! text input knows the string the user has typed, a checkbox knows
//! whether it's checked. Rather than encoding "which kind of state does
//! this widget have" inside the layout enum, every [`Widget`](super::widget::Widget)
//! has an optional `value: Option<WidgetValue>` orthogonal to its `kind`.
//! That keeps the layout algorithm simple (it doesn't care about state)
//! and lets the same `Leaf` form a button (no value) or a checkbox
//! (`Bool` value).
//!
//! # Data binding model
//!
//! Stage-8 piece-6 uses the **immediate-mode** pattern (the same as
//! `egui` and Bevy UI): the widget tree is the source of truth for the
//! frame. Each frame the host:
//!
//! 1. Pulls latest game data into the matching widget values (e.g.,
//! `root.set_value("volume", WidgetValue::Float(audio.master_volume as f64))`).
//! 2. Runs the [`Router`](super::routing::Router).
//! 3. Reads back any widget values that interactive widgets may have
//! changed, and pushes them into game data
//! (`audio.master_volume = root.value("volume")?.as_float()? as f32`).
//!
//! No callback storage, no `Rc<RefCell<...>>` for state, no lifetime
//! gymnastics — exactly what a game's main loop wants.
use serde::{Deserialize, Serialize};
/// A typed value carried on an interactive widget — the slider's
/// position, a checkbox's check, a text-input's string.
///
/// Variants are intentionally minimal; richer types (Color, Vec2, etc.)
/// can be added as widget needs grow. RON round-trips so a UI document
/// can ship default values inline.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WidgetValue {
Bool(bool),
Int(i64),
Float(f64),
Text(String),
}
impl WidgetValue {
/// Borrow as a bool if this is a [`Bool`](Self::Bool).
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(v) => Some(*v),
_ => None,
}
}
/// Borrow as an i64 if this is an [`Int`](Self::Int).
pub fn as_int(&self) -> Option<i64> {
match self {
Self::Int(v) => Some(*v),
_ => None,
}
}
/// Borrow as an f64 if this is a [`Float`](Self::Float).
pub fn as_float(&self) -> Option<f64> {
match self {
Self::Float(v) => Some(*v),
_ => None,
}
}
/// Borrow as a string slice if this is a [`Text`](Self::Text).
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s.as_str()),
_ => None,
}
}
}
impl From<bool> for WidgetValue {
fn from(v: bool) -> Self {
Self::Bool(v)
}
}
impl From<i64> for WidgetValue {
fn from(v: i64) -> Self {
Self::Int(v)
}
}
impl From<i32> for WidgetValue {
fn from(v: i32) -> Self {
Self::Int(v as i64)
}
}
impl From<f64> for WidgetValue {
fn from(v: f64) -> Self {
Self::Float(v)
}
}
impl From<f32> for WidgetValue {
fn from(v: f32) -> Self {
Self::Float(v as f64)
}
}
impl From<String> for WidgetValue {
fn from(v: String) -> Self {
Self::Text(v)
}
}
impl From<&str> for WidgetValue {
fn from(v: &str) -> Self {
Self::Text(v.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn as_accessors_match_variants() {
assert_eq!(WidgetValue::Bool(true).as_bool(), Some(true));
assert_eq!(WidgetValue::Bool(true).as_int(), None);
assert_eq!(WidgetValue::Int(42).as_int(), Some(42));
assert_eq!(WidgetValue::Float(1.5).as_float(), Some(1.5));
assert_eq!(WidgetValue::Text("hi".into()).as_text(), Some("hi"));
}
#[test]
fn primitive_conversions() {
let v: WidgetValue = true.into();
assert_eq!(v, WidgetValue::Bool(true));
let v: WidgetValue = 7_i32.into();
assert_eq!(v, WidgetValue::Int(7));
let v: WidgetValue = 1.5_f32.into();
assert!((v.as_float().unwrap() - 1.5_f64).abs() < 1e-5);
let v: WidgetValue = "label".into();
assert_eq!(v.as_text(), Some("label"));
}
#[test]
fn ron_round_trips_each_variant() {
for v in [
WidgetValue::Bool(true),
WidgetValue::Int(-99),
WidgetValue::Float(0.42),
WidgetValue::Text("hello".into()),
] {
let text = ron::ser::to_string(&v).unwrap();
let decoded: WidgetValue = ron::de::from_str(&text).unwrap();
assert_eq!(v, decoded);
}
}
}
+324
View File
@@ -0,0 +1,324 @@
//! Visual style — colors, borders, fonts. The *what does it look like* layer.
//!
//! [`VisualStyle`] is orthogonal to the Stage-8 [`LayoutStyle`](super::style::LayoutStyle):
//! layout decides where a widget *is*; visual decides what it *looks like*.
//! Every field is `Option<T>`. `None` means **inherit** — from a [`Theme`](super::theme::Theme)
//! when present, otherwise from the renderer's hard-coded fallback in piece 4.
//! `Some` means **override**: this widget (or this named theme style) wants
//! exactly this value, regardless of what the theme provides.
//!
//! Why optional fields instead of full values: it lets a tiny per-widget
//! override stay tiny in RON (one line for "button-pressed has a brighter
//! background") without re-stating every color/border/font the theme already
//! provides. The same merging rule works equally well for theme cascades
//! (default → named style → per-instance) and for runtime state changes
//! (hover/focus/press overlays in piece 5).
use serde::{Deserialize, Serialize};
use crate::asset::AssetRef;
use crate::math::Color;
use super::text::Font;
/// Optional per-widget visual properties. `None` on a field means "inherit";
/// `Some` means "override".
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct VisualStyle {
/// Filled background color drawn behind the widget's `content_rect`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<Color>,
/// Foreground color — text, icons, anything drawn *on top of* the
/// background.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground: Option<Color>,
/// Border drawn around the widget's `rect`. `Some(border)` with a
/// `width <= 0.0` is treated as "no border" by the renderer, the same as
/// `None`, but the value still serializes — useful for theme overrides
/// that explicitly *suppress* a border.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border: Option<Border>,
/// Corner radius in logical pixels (zero means square). Applies to both
/// background fill and border.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub corner_radius: Option<f32>,
/// Font family + weight + italic flag. Piece 3 turns this into a
/// shaped glyph stream.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font: Option<FontRef>,
/// A specific font **asset** to draw with, chosen in the editor's UI canvas
/// from the project's `fonts/`. When set it takes precedence over the
/// portable [`font`](Self::font) descriptor (the renderer resolves the
/// [`AssetRef`] to a loaded face via the asset database); when `None` the
/// descriptor / theme path applies as before. This is the engine's first
/// `AssetRef<T>` field — the asset-picker's end-to-end target.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_asset: Option<AssetRef<Font>>,
/// Font size in logical pixels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f32>,
}
impl VisualStyle {
/// Empty style — every field `None`. Equivalent to [`Default::default`];
/// `EMPTY` exists as a `const` for places that want it as an associated
/// constant.
pub const EMPTY: Self = Self {
background: None,
foreground: None,
border: None,
corner_radius: None,
font: None,
font_asset: None,
font_size: None,
};
/// Returns a style where every `Some` field in `override_with` replaces
/// the corresponding field in `self`.
///
/// This is the merge primitive themes and runtime state use: build a
/// resolved style by cascading default → named-style → per-instance →
/// state-overlay, each call replacing only the fields the caller cared
/// about.
pub fn merged(&self, override_with: &VisualStyle) -> VisualStyle {
VisualStyle {
background: override_with.background.or(self.background),
foreground: override_with.foreground.or(self.foreground),
border: override_with.border.or(self.border),
corner_radius: override_with.corner_radius.or(self.corner_radius),
font: override_with.font.clone().or_else(|| self.font.clone()),
font_asset: override_with.font_asset.or(self.font_asset),
font_size: override_with.font_size.or(self.font_size),
}
}
/// True if every field is `None`. Handy as a `skip_serializing_if` test
/// when embedding a `VisualStyle` in a host struct that wants the empty
/// case to vanish from RON entirely.
pub fn is_empty(&self) -> bool {
*self == Self::EMPTY
}
}
/// Border drawn around a widget's `rect`.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Border {
pub color: Color,
pub width: f32,
}
impl Border {
pub const fn new(color: Color, width: f32) -> Self {
Self { color, width }
}
}
/// Reference to a font face the renderer will load and shape with.
///
/// Piece 2 stores the descriptor only; piece 3 (text shaping & glyph atlas)
/// resolves it to an actual loaded face. Keeping the descriptor as plain
/// `family` + `weight` + `italic` (rather than a path or a handle) means UI
/// documents are portable: a theme can ask for `"Inter"` and the runtime can
/// pick the platform's best match for that name without rewriting the
/// document.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FontRef {
pub family: String,
#[serde(default, skip_serializing_if = "FontWeight::is_default")]
pub weight: FontWeight,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub italic: bool,
}
impl FontRef {
/// Regular-weight, upright font of the given family.
pub fn regular(family: impl Into<String>) -> Self {
Self {
family: family.into(),
weight: FontWeight::Regular,
italic: false,
}
}
/// Bold-weight, upright font of the given family.
pub fn bold(family: impl Into<String>) -> Self {
Self {
family: family.into(),
weight: FontWeight::Bold,
italic: false,
}
}
}
/// Font weight — the named buckets the OpenType weight axis snaps to.
///
/// Stored as a discrete enum (rather than a `u16` 100900) because the
/// editor's style inspector and a hand-edited RON file both want
/// `weight: Bold` to round-trip exactly. Renderers can map each variant to
/// its OpenType weight value in piece 3.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum FontWeight {
Thin,
Light,
#[default]
Regular,
Medium,
Bold,
Black,
}
impl FontWeight {
/// OpenType weight value (100..=900) for this bucket.
pub fn opentype_value(self) -> u16 {
match self {
Self::Thin => 100,
Self::Light => 300,
Self::Regular => 400,
Self::Medium => 500,
Self::Bold => 700,
Self::Black => 900,
}
}
fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_style_has_no_set_fields() {
let s = VisualStyle::default();
assert!(s.is_empty());
assert_eq!(s, VisualStyle::EMPTY);
}
#[test]
fn merge_overrides_only_set_fields() {
let base = VisualStyle {
background: Some(Color::WHITE),
foreground: Some(Color::BLACK),
border: Some(Border::new(Color::BLACK, 1.0)),
corner_radius: Some(4.0),
font: Some(FontRef::regular("Inter")),
font_asset: None,
font_size: Some(14.0),
};
let overlay = VisualStyle {
background: Some(Color::rgb(0.9, 0.9, 0.9)),
font_size: Some(16.0),
..VisualStyle::EMPTY
};
let merged = base.merged(&overlay);
assert_eq!(merged.background, Some(Color::rgb(0.9, 0.9, 0.9))); // overlaid
assert_eq!(merged.foreground, Some(Color::BLACK)); // kept from base
assert_eq!(merged.font_size, Some(16.0)); // overlaid
assert_eq!(merged.corner_radius, Some(4.0)); // kept from base
assert_eq!(merged.font, Some(FontRef::regular("Inter")));
}
#[test]
fn merge_with_empty_overlay_is_identity() {
let base = VisualStyle {
background: Some(Color::WHITE),
foreground: Some(Color::BLACK),
..VisualStyle::EMPTY
};
assert_eq!(base.merged(&VisualStyle::EMPTY), base);
}
#[test]
fn merge_into_empty_base_takes_overlay() {
let overlay = VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
};
assert_eq!(VisualStyle::EMPTY.merged(&overlay), overlay);
}
#[test]
fn font_ref_helpers_match_fields() {
let r = FontRef::regular("Inter");
assert_eq!(r.family, "Inter");
assert_eq!(r.weight, FontWeight::Regular);
assert!(!r.italic);
let b = FontRef::bold("Inter");
assert_eq!(b.weight, FontWeight::Bold);
}
#[test]
fn font_weight_opentype_value() {
assert_eq!(FontWeight::Thin.opentype_value(), 100);
assert_eq!(FontWeight::Regular.opentype_value(), 400);
assert_eq!(FontWeight::Bold.opentype_value(), 700);
assert_eq!(FontWeight::Black.opentype_value(), 900);
}
#[test]
fn visual_style_round_trips_through_ron_compactly() {
let s = VisualStyle {
background: Some(Color::WHITE),
corner_radius: Some(8.0),
font: Some(FontRef::bold("Inter")),
..VisualStyle::EMPTY
};
let text = ron::ser::to_string(&s).unwrap();
// Fields that are `None` must not appear in the serialized form.
assert!(!text.contains("foreground"));
assert!(!text.contains("border"));
assert!(!text.contains("font_size"));
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
assert_eq!(s, decoded);
}
#[test]
fn font_asset_overrides_and_round_trips() {
use crate::asset::{AssetRef, AssetUid};
// An overlay's font_asset replaces the base's, like the other fields.
let base = VisualStyle {
font_asset: Some(AssetRef::new(AssetUid(1))),
..VisualStyle::EMPTY
};
let overlay = VisualStyle {
font_asset: Some(AssetRef::new(AssetUid(2))),
..VisualStyle::EMPTY
};
assert_eq!(
base.merged(&overlay).font_asset,
Some(AssetRef::new(AssetUid(2)))
);
// An empty overlay keeps the base reference (inherit semantics).
assert_eq!(base.merged(&VisualStyle::EMPTY).font_asset, base.font_asset);
// Round-trips compactly and is skipped when unset.
let text = ron::ser::to_string(&base).unwrap();
assert!(text.contains("font_asset"));
assert_eq!(ron::de::from_str::<VisualStyle>(&text).unwrap(), base);
let empty_text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
assert!(!empty_text.contains("font_asset"));
}
#[test]
fn empty_style_round_trips_to_empty_ron() {
let text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
// No fields set → the struct should serialize to its empty form.
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
assert_eq!(decoded, VisualStyle::EMPTY);
}
#[test]
fn font_ref_defaults_skip_in_ron() {
let f = FontRef::regular("Inter");
let text = ron::ser::to_string(&f).unwrap();
// Regular weight and non-italic should be skipped.
assert!(!text.contains("Regular"));
assert!(!text.contains("italic"));
let decoded: FontRef = ron::de::from_str(&text).unwrap();
assert_eq!(f, decoded);
}
}
+933
View File
@@ -0,0 +1,933 @@
//! Widget tree — the data structure laid out by [`super::layout`].
//!
//! Stage 8 splits widgets cleanly into **what** (the [`WidgetKind`]) and
//! **how** (the [`LayoutStyle`] held on every node). The kind decides whether
//! a node has children and how they're arranged; the style is the same fields
//! on every widget so the layout algorithm has one place to look.
//!
//! Piece 1 ships only what the layout algorithm needs: a [`Leaf`](WidgetKind::Leaf)
//! placeholder with an intrinsic size, and three container kinds — [`Stack`]
//! (row/column), [`Grid`], and [`AnchorGroup`]. Interactive widgets (button,
//! checkbox, slider, text input, …) are layered on top in later pieces by
//! decorating leaves with kind-specific style/state; they all participate in
//! the same layout pass without the algorithm having to know about them.
//!
//! # Building a tree
//!
//! ```
//! use glam::Vec2;
//! use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
//!
//! let panel = Widget::row()
//! .with_id("toolbar")
//! .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(80.0, 24.0)).with_id("file"))
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("edit"));
//! assert_eq!(panel.children().len(), 2);
//! ```
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::style::LayoutStyle;
use super::value::WidgetValue;
use super::visual::VisualStyle;
/// Stable identifier for a widget — used to look up its laid-out rect in a
/// [`LayoutTree`](super::layout::LayoutTree) and (in later pieces) to wire up
/// input routing and data binding.
///
/// Stored as `String` so UI documents can ship author-facing names (`"play"`,
/// `"volume-slider"`) straight through RON. The empty id (`""`) is the default
/// and means "anonymous"; multiple anonymous widgets are allowed and lookups
/// by empty id are rejected by [`LayoutTree::find`](super::layout::LayoutTree::find).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WidgetId(pub String);
impl WidgetId {
/// `true` if the id string is empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Borrow the underlying string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for WidgetId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
/// A path from a root [`Widget`] to one of its descendants: the sequence of
/// child indices to follow from the root. The **empty** path denotes the root
/// itself.
///
/// Unlike [`WidgetId`] (optional, author-facing, possibly absent or duplicated)
/// a path addresses *exactly one* node positionally, so it is what the editor's
/// UI canvas uses to target structural edits — insert, remove, move — and to
/// record them on the undo stack. Paths are only valid against the tree they
/// were derived from; an edit that changes sibling order invalidates the paths
/// after it (the move helper accounts for this itself).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WidgetPath(pub Vec<usize>);
impl WidgetPath {
/// The root path (addresses the tree's root widget).
pub fn root() -> Self {
Self(Vec::new())
}
/// Whether this path addresses the root (is empty).
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
/// Depth from the root (number of indices).
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether the path is empty — alias of [`is_root`](Self::is_root), provided
/// for the clippy `len`/`is_empty` pairing.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// A child path one level deeper, selecting child `index`.
pub fn child(&self, index: usize) -> Self {
let mut v = self.0.clone();
v.push(index);
Self(v)
}
/// Splits into `(parent_path, last_index)`, or `None` for the root.
pub fn split_last(&self) -> Option<(WidgetPath, usize)> {
let (last, rest) = self.0.split_last()?;
Some((WidgetPath(rest.to_vec()), *last))
}
/// Whether `self` is `other` or lies underneath it (prefix test). Used to
/// reject moving a subtree into its own descendant.
pub fn starts_with(&self, other: &WidgetPath) -> bool {
self.0.starts_with(&other.0)
}
}
impl From<String> for WidgetId {
fn from(s: String) -> Self {
Self(s)
}
}
/// A widget tree node — id, layout style, optional visual style + theme
/// reference, and a kind that decides what children it holds.
///
/// `style` (Stage-8 piece 1) controls layout — where the widget is.
/// `visual` (piece 2) carries per-instance visual overrides — what the
/// widget looks like — and `theme_style` opts into a named entry in the
/// project's [`Theme`](super::theme::Theme). Both default to empty so a
/// piece-1 UI document still parses unchanged.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Widget {
#[serde(default, skip_serializing_if = "WidgetId::is_empty")]
pub id: WidgetId,
#[serde(default)]
pub style: LayoutStyle,
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
pub visual: VisualStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme_style: Option<String>,
/// Text content shaped inside this widget's `content_rect`. Orthogonal
/// to `kind`: a button is a `Leaf` with `text` + `visual.background`; a
/// label is a `Leaf` with `text` only. Renderers shape this string
/// against the resolved [`VisualStyle::font`] and [`VisualStyle::font_size`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// Per-widget typed state — `Bool` for a checkbox, `Float` for a
/// slider, `Text` for a text input. Orthogonal to `kind`; absent
/// means "no state". See [`super::value::WidgetValue`] and the
/// piece-6 [`Widget::value`](Self::value) / [`set_value`](Self::set_value)
/// helpers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<WidgetValue>,
pub kind: WidgetKind,
}
/// What a widget *is* — leaf or one of three container layout modes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WidgetKind {
/// A childless node with an intrinsic logical size. Real interactive
/// widgets (label, button, image) layer on top of this in later pieces.
Leaf { intrinsic: Vec2 },
/// Row or column container.
Stack(Stack),
/// Equal-cell grid container.
Grid(Grid),
/// Container that positions each child via the child's own
/// [`Anchor`](super::style::Anchor).
Anchor(AnchorGroup),
}
impl Default for WidgetKind {
fn default() -> Self {
Self::Leaf {
intrinsic: Vec2::ZERO,
}
}
}
/// Stack container — arranges children along a main axis.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Stack {
pub direction: StackDirection,
/// Logical-pixel gap between adjacent children.
#[serde(default)]
pub gap: f32,
/// How leftover space on the main axis is distributed *after* children
/// have been sized. Ignored when any child uses [`Sizing::Grow`](super::style::Sizing::Grow),
/// since `Grow` consumes the leftover space directly.
#[serde(default)]
pub main_align: super::style::Align,
#[serde(default)]
pub children: Vec<Widget>,
}
/// Direction of a [`Stack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StackDirection {
/// Children flow left-to-right.
#[default]
Row,
/// Children flow top-to-bottom.
Column,
}
/// Equal-cell grid container — `cols × rows` cells filled in row-major order.
///
/// Piece-1 grids are intentionally simple: every cell is the same size,
/// computed from the parent's content rect. More flexible grids (auto-sized
/// rows/columns, spans) are a follow-up; the use cases the editor's Stage-7
/// preferences page and the Stage-8 settings examples actually need are all
/// served by the equal-cell case.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Grid {
pub cols: u32,
pub rows: u32,
/// `gap.x` between columns, `gap.y` between rows (logical pixels).
#[serde(default)]
pub gap: Vec2,
#[serde(default)]
pub children: Vec<Widget>,
}
/// Anchor container — each child is placed according to its own
/// [`LayoutStyle::anchor`](super::style::LayoutStyle::anchor).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AnchorGroup {
#[serde(default)]
pub children: Vec<Widget>,
}
impl Widget {
/// Build a leaf widget with the given intrinsic logical size.
pub fn leaf(intrinsic: Vec2) -> Self {
Self {
kind: WidgetKind::Leaf { intrinsic },
..Default::default()
}
}
/// Build an empty stack with the given direction (gap 0, default align).
pub fn stack(direction: StackDirection) -> Self {
Self {
kind: WidgetKind::Stack(Stack {
direction,
..Default::default()
}),
..Default::default()
}
}
/// Shortcut for `Widget::stack(StackDirection::Row)`.
pub fn row() -> Self {
Self::stack(StackDirection::Row)
}
/// Shortcut for `Widget::stack(StackDirection::Column)`.
pub fn column() -> Self {
Self::stack(StackDirection::Column)
}
/// Build an empty grid container.
pub fn grid(cols: u32, rows: u32) -> Self {
Self {
kind: WidgetKind::Grid(Grid {
cols,
rows,
..Default::default()
}),
..Default::default()
}
}
/// Build an empty anchor container.
pub fn anchor() -> Self {
Self {
kind: WidgetKind::Anchor(AnchorGroup::default()),
..Default::default()
}
}
/// Set the widget id (builder).
pub fn with_id(mut self, id: impl Into<WidgetId>) -> Self {
self.id = id.into();
self
}
/// Replace the whole [`LayoutStyle`] (builder).
pub fn with_style(mut self, style: LayoutStyle) -> Self {
self.style = style;
self
}
/// Replace the per-instance [`VisualStyle`] (builder).
pub fn with_visual(mut self, visual: VisualStyle) -> Self {
self.visual = visual;
self
}
/// Opt this widget into a named entry of the active
/// [`Theme`](super::theme::Theme) (builder). Pass `""` or call
/// [`Widget::clear_theme_style`] to remove the reference.
pub fn with_theme_style(mut self, name: impl Into<String>) -> Self {
let name = name.into();
self.theme_style = if name.is_empty() { None } else { Some(name) };
self
}
/// Drop any `theme_style` reference (builder).
pub fn clear_theme_style(mut self) -> Self {
self.theme_style = None;
self
}
/// Set this widget's text content (builder). Pass `""` to clear it. The
/// text is shaped at paint time against the widget's resolved font and
/// font size from the active theme.
pub fn with_text(mut self, text: impl Into<String>) -> Self {
let s = text.into();
self.text = if s.is_empty() { None } else { Some(s) };
self
}
/// Set this widget's typed value (builder).
pub fn with_value(mut self, value: impl Into<WidgetValue>) -> Self {
self.value = Some(value.into());
self
}
/// Set the stack gap (builder). Panics if not a stack — surfaces author
/// mistakes during construction rather than producing a silently
/// misshapen UI at layout time.
pub fn with_gap(mut self, gap: f32) -> Self {
match &mut self.kind {
WidgetKind::Stack(s) => s.gap = gap,
_ => panic!("with_gap is only valid on Stack widgets"),
}
self
}
/// Set the stack main-axis alignment (builder). Panics if not a stack.
pub fn with_main_align(mut self, align: super::style::Align) -> Self {
match &mut self.kind {
WidgetKind::Stack(s) => s.main_align = align,
_ => panic!("with_main_align is only valid on Stack widgets"),
}
self
}
/// Set the grid gap vector (builder). Panics if not a grid.
pub fn with_grid_gap(mut self, gap: Vec2) -> Self {
match &mut self.kind {
WidgetKind::Grid(g) => g.gap = gap,
_ => panic!("with_grid_gap is only valid on Grid widgets"),
}
self
}
/// Append a single child to a container widget (builder). Panics on a
/// leaf so the misuse is caught at construction.
pub fn with_child(mut self, child: Widget) -> Self {
children_mut(&mut self.kind, |c| c.push(child));
self
}
/// Append many children (builder).
pub fn with_children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
children_mut(&mut self.kind, |c| c.extend(children));
self
}
/// Borrow the direct children of this widget. Empty for leaves.
pub fn children(&self) -> &[Widget] {
match &self.kind {
WidgetKind::Leaf { .. } => &[],
WidgetKind::Stack(s) => &s.children,
WidgetKind::Grid(g) => &g.children,
WidgetKind::Anchor(a) => &a.children,
}
}
/// Borrow the direct children mutably. Empty slice for leaves.
///
/// Underpins [`find_by_id_mut`](Self::find_by_id_mut) and the piece-6
/// data-binding helpers; safer than reaching into `kind` because all
/// container kinds funnel through one accessor.
pub fn children_mut(&mut self) -> &mut [Widget] {
match &mut self.kind {
WidgetKind::Leaf { .. } => &mut [],
WidgetKind::Stack(s) => &mut s.children,
WidgetKind::Grid(g) => &mut g.children,
WidgetKind::Anchor(a) => &mut a.children,
}
}
/// Borrow this widget's children as the owning `Vec`, or `None` for a
/// [`Leaf`](WidgetKind::Leaf) (which cannot hold children). Unlike
/// [`children_mut`](Self::children_mut) this exposes the `Vec` itself, so
/// callers can insert/remove — the basis of the structural edits below.
pub fn children_vec_mut(&mut self) -> Option<&mut Vec<Widget>> {
match &mut self.kind {
WidgetKind::Leaf { .. } => None,
WidgetKind::Stack(s) => Some(&mut s.children),
WidgetKind::Grid(g) => Some(&mut g.children),
WidgetKind::Anchor(a) => Some(&mut a.children),
}
}
/// Whether this widget is a container (can hold children) rather than a leaf.
pub fn is_container(&self) -> bool {
!matches!(self.kind, WidgetKind::Leaf { .. })
}
/// Borrow the widget addressed by `path` (the root for the empty path), or
/// `None` if any index along the way is out of range.
pub fn get_path(&self, path: &WidgetPath) -> Option<&Widget> {
let mut node = self;
for &i in &path.0 {
node = node.children().get(i)?;
}
Some(node)
}
/// Mutable counterpart of [`get_path`](Self::get_path).
pub fn get_path_mut(&mut self, path: &WidgetPath) -> Option<&mut Widget> {
let mut node = self;
for &i in &path.0 {
node = node.children_mut().get_mut(i)?;
}
Some(node)
}
/// Inserts `child` at `index` among the children of the widget addressed by
/// `parent`, returning whether it succeeded. `index` is clamped to the
/// child count (so it can append). Fails if `parent` does not resolve or is
/// a leaf.
pub fn insert_child(&mut self, parent: &WidgetPath, index: usize, child: Widget) -> bool {
let Some(parent) = self.get_path_mut(parent) else {
return false;
};
let Some(children) = parent.children_vec_mut() else {
return false;
};
children.insert(index.min(children.len()), child);
true
}
/// Appends `child` to the children of the widget addressed by `parent`.
/// Convenience over [`insert_child`](Self::insert_child) with a trailing
/// index.
pub fn push_child_at(&mut self, parent: &WidgetPath, child: Widget) -> bool {
self.insert_child(parent, usize::MAX, child)
}
/// Removes and returns the widget addressed by `path`. The root cannot be
/// removed (returns `None` for the empty path), nor can an out-of-range or
/// unreachable path.
pub fn remove_path(&mut self, path: &WidgetPath) -> Option<Widget> {
let (parent, index) = path.split_last()?;
let children = self.get_path_mut(&parent)?.children_vec_mut()?;
(index < children.len()).then(|| children.remove(index))
}
/// Moves the subtree at `from` to be child `index` of `to_parent`,
/// returning whether it succeeded. Rejects moving the root, or moving a node
/// into itself or one of its own descendants. Sibling indices shift when the
/// node is detached, so both `to_parent` and `index` are adjusted internally
/// to mean what the caller intended *before* the move.
pub fn move_subtree(
&mut self,
from: &WidgetPath,
to_parent: &WidgetPath,
index: usize,
) -> bool {
if from.is_root() || to_parent.starts_with(from) {
return false;
}
// The destination must exist and be a container; check before detaching
// (removing `from`, which is not an ancestor of `to_parent`, leaves the
// destination node itself unchanged — only its path may shift).
if !self.get_path(to_parent).is_some_and(Widget::is_container) {
return false;
}
let Some(node) = self.remove_path(from) else {
return false;
};
let to_parent = adjust_path_for_removal(to_parent, from);
let (from_parent, from_index) = from.split_last().expect("non-root checked above");
// Inserting back into the same parent after the detach point shifts the
// target slot down by one.
let index = if from_parent.0 == to_parent.0 && from_index < index {
index - 1
} else {
index
};
self.insert_child(&to_parent, index, node)
}
/// Find a descendant (or self) with this id. Returns the first match
/// in pre-order. `None` if no widget matches (or `id` is empty).
pub fn find_by_id(&self, id: &WidgetId) -> Option<&Widget> {
if id.is_empty() {
return None;
}
if self.id == *id {
return Some(self);
}
for child in self.children() {
if let Some(found) = child.find_by_id(id) {
return Some(found);
}
}
None
}
/// Mutable counterpart of [`find_by_id`](Self::find_by_id).
pub fn find_by_id_mut(&mut self, id: &WidgetId) -> Option<&mut Widget> {
if id.is_empty() {
return None;
}
if self.id == *id {
return Some(self);
}
for child in self.children_mut() {
if let Some(found) = child.find_by_id_mut(id) {
return Some(found);
}
}
None
}
/// Borrow the [`WidgetValue`] of the descendant with this id, if any.
/// One half of the piece-6 data-binding loop: read what the UI says.
pub fn value(&self, id: &WidgetId) -> Option<&WidgetValue> {
self.find_by_id(id).and_then(|w| w.value.as_ref())
}
/// Set the [`WidgetValue`] of the descendant with this id, returning
/// `true` if such a widget exists. The other half of the piece-6
/// data-binding loop: write game state into the UI.
pub fn set_value(&mut self, id: &WidgetId, value: impl Into<WidgetValue>) -> bool {
match self.find_by_id_mut(id) {
Some(w) => {
w.value = Some(value.into());
true
}
None => false,
}
}
/// Recursive count of nodes including `self`. Handy for sanity checks
/// in tests when comparing against a [`LayoutTree::nodes`](super::layout::LayoutTree::nodes)
/// length.
pub fn node_count(&self) -> usize {
1 + self
.children()
.iter()
.map(Widget::node_count)
.sum::<usize>()
}
/// Resolve this widget's effective [`VisualStyle`] under a given theme,
/// cascading `theme.default` → `theme.styles[self.theme_style]` →
/// `self.visual`. See [`Theme::resolve`](super::theme::Theme::resolve)
/// for the merge rules. Children are *not* recursively resolved here —
/// piece 4 walks the tree pairing each [`super::layout::LayoutNode`] with
/// its resolved style.
pub fn resolve_visual(&self, theme: &super::theme::Theme) -> VisualStyle {
theme.resolve(self.theme_style.as_deref(), &self.visual)
}
/// Serialize this widget tree to a pretty-printed RON string — the
/// canonical UI-document format an editor saves and the runtime loads.
pub fn to_ron(&self) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
}
/// Parse a widget tree from a RON string produced by [`to_ron`](Self::to_ron).
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
ron::de::from_str(text)
}
}
/// Rewrites `path` to stay valid after the widget at `removed` is detached.
///
/// Detaching shifts the later siblings of `removed` down by one. A path is
/// affected only if it descends through `removed`'s parent and its index at
/// that depth is *after* the removed index; then that one index decrements.
/// `path` must not be `removed` or beneath it (the caller guarantees this).
fn adjust_path_for_removal(path: &WidgetPath, removed: &WidgetPath) -> WidgetPath {
let Some((removed_parent, removed_index)) = removed.split_last() else {
return path.clone();
};
let depth = removed_parent.0.len();
let mut out = path.0.clone();
if out.len() > depth && out[..depth] == removed_parent.0[..] && out[depth] > removed_index {
out[depth] -= 1;
}
WidgetPath(out)
}
fn children_mut(kind: &mut WidgetKind, f: impl FnOnce(&mut Vec<Widget>)) {
match kind {
WidgetKind::Stack(s) => f(&mut s.children),
WidgetKind::Grid(g) => f(&mut g.children),
WidgetKind::Anchor(a) => f(&mut a.children),
WidgetKind::Leaf { .. } => panic!("cannot add children to a Leaf widget"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A row root with three leaf children id'd "a","b","c".
fn abc_tree() -> Widget {
Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"))
}
fn ids_of(children: &[Widget]) -> Vec<&str> {
children.iter().map(|w| w.id.as_str()).collect()
}
#[test]
fn get_path_addresses_nodes() {
let root = abc_tree();
assert_eq!(
root.get_path(&WidgetPath::root()).unwrap().id.as_str(),
"root"
);
assert_eq!(
root.get_path(&WidgetPath(vec![1])).unwrap().id.as_str(),
"b"
);
assert!(root.get_path(&WidgetPath(vec![9])).is_none());
}
#[test]
fn insert_and_remove_children_by_path() {
let mut root = abc_tree();
// Insert "x" between a and b.
assert!(root.insert_child(
&WidgetPath::root(),
1,
Widget::leaf(Vec2::ZERO).with_id("x")
));
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c"]);
// Append "z" via the clamping path.
assert!(root.push_child_at(&WidgetPath::root(), Widget::leaf(Vec2::ZERO).with_id("z")));
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c", "z"]);
// A leaf rejects children; the root cannot be removed.
assert!(!root.insert_child(&WidgetPath(vec![0]), 0, Widget::default()));
assert!(root.remove_path(&WidgetPath::root()).is_none());
// Remove "x".
let removed = root.remove_path(&WidgetPath(vec![1])).unwrap();
assert_eq!(removed.id.as_str(), "x");
assert_eq!(ids_of(root.children()), ["a", "b", "c", "z"]);
}
#[test]
fn move_subtree_reorders_within_parent() {
let mut root = abc_tree();
// Move "a" (index 0) to the end (index 3 in pre-removal terms).
assert!(root.move_subtree(&WidgetPath(vec![0]), &WidgetPath::root(), 3));
assert_eq!(ids_of(root.children()), ["b", "c", "a"]);
}
#[test]
fn move_subtree_across_branches_adjusts_paths() {
// root[ col(0) [a], b(1), c(2) ]: move c into the column before a.
let mut root = Widget::row()
.with_id("root")
.with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
)
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"));
assert!(root.move_subtree(&WidgetPath(vec![2]), &WidgetPath(vec![0]), 0));
// c now leads the column; root has col + b left.
assert_eq!(
ids_of(root.get_path(&WidgetPath(vec![0])).unwrap().children()),
["c", "a"]
);
assert_eq!(ids_of(root.children()), ["col", "b"]);
}
#[test]
fn move_subtree_rejects_into_own_descendant_and_root() {
let mut root = Widget::row().with_id("root").with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
);
// Can't move "col" (path [0]) under its own child "a" (path [0,0]).
assert!(!root.move_subtree(&WidgetPath(vec![0]), &WidgetPath(vec![0, 0]), 0));
// Can't move the root.
assert!(!root.move_subtree(&WidgetPath::root(), &WidgetPath(vec![0]), 0));
// Tree is unchanged.
assert_eq!(ids_of(root.children()), ["col"]);
}
#[test]
fn widget_id_from_str_and_string() {
let a: WidgetId = "abc".into();
let b: WidgetId = String::from("abc").into();
assert_eq!(a, b);
assert_eq!(a.as_str(), "abc");
assert!(!a.is_empty());
assert!(WidgetId::default().is_empty());
}
#[test]
fn default_widget_is_zero_leaf() {
let w = Widget::default();
assert_eq!(w.id, WidgetId::default());
assert_eq!(w.style, LayoutStyle::default());
assert!(matches!(w.kind, WidgetKind::Leaf { intrinsic } if intrinsic == Vec2::ZERO));
}
#[test]
fn builder_methods_compose() {
let w = Widget::row()
.with_id("toolbar")
.with_gap(4.0)
.with_main_align(super::super::style::Align::Center)
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
.with_children([Widget::leaf(Vec2::new(20.0, 10.0)).with_id("b")]);
assert_eq!(w.id.as_str(), "toolbar");
let WidgetKind::Stack(s) = &w.kind else {
panic!("expected stack");
};
assert_eq!(s.direction, StackDirection::Row);
assert_eq!(s.gap, 4.0);
assert_eq!(s.main_align, super::super::style::Align::Center);
assert_eq!(s.children.len(), 2);
assert_eq!(s.children[0].id.as_str(), "a");
assert_eq!(s.children[1].id.as_str(), "b");
}
#[test]
#[should_panic(expected = "cannot add children to a Leaf widget")]
fn adding_child_to_leaf_panics() {
let _ = Widget::leaf(Vec2::new(1.0, 1.0)).with_child(Widget::leaf(Vec2::ONE));
}
#[test]
#[should_panic(expected = "with_gap is only valid on Stack widgets")]
fn gap_on_non_stack_panics() {
let _ = Widget::grid(2, 2).with_gap(4.0);
}
#[test]
fn node_count_recurses() {
let tree = Widget::row()
.with_child(Widget::leaf(Vec2::ONE))
.with_child(
Widget::column()
.with_child(Widget::leaf(Vec2::ONE))
.with_child(Widget::leaf(Vec2::ONE)),
);
// root + leaf + (column + 2 leaves) = 5
assert_eq!(tree.node_count(), 5);
}
#[test]
fn widget_round_trips_through_ron() {
let w = Widget::row()
.with_id("root")
.with_gap(8.0)
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a"))
.with_child(Widget::anchor().with_child(Widget::leaf(Vec2::new(10.0, 10.0))));
let text = ron::ser::to_string_pretty(&w, ron::ser::PrettyConfig::default()).unwrap();
let decoded: Widget = ron::de::from_str(&text).unwrap();
assert_eq!(w, decoded);
}
#[test]
fn visual_and_theme_style_builders_set_fields() {
use super::super::visual::VisualStyle;
use crate::math::Color;
let w = Widget::leaf(Vec2::ONE)
.with_id("a")
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
})
.with_theme_style("button");
assert_eq!(w.visual.background, Some(Color::RED));
assert_eq!(w.theme_style.as_deref(), Some("button"));
// Passing an empty string drops the reference.
let cleared = w.clone().with_theme_style("");
assert_eq!(cleared.theme_style, None);
let explicitly_cleared = w.clear_theme_style();
assert_eq!(explicitly_cleared.theme_style, None);
}
#[test]
fn resolve_visual_cascades_theme_named_overrides() {
use super::super::theme::Theme;
use super::super::visual::VisualStyle;
use crate::math::Color;
let theme = Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
..VisualStyle::EMPTY
},
);
let w = Widget::leaf(Vec2::ONE)
.with_theme_style("button")
.with_visual(VisualStyle {
foreground: Some(Color::RED),
..VisualStyle::EMPTY
});
let resolved = w.resolve_visual(&theme);
assert_eq!(resolved.foreground, Some(Color::RED)); // per-instance
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // named
}
#[test]
fn widget_with_visual_and_theme_style_round_trips_through_ron() {
use super::super::visual::{FontRef, VisualStyle};
use crate::math::Color;
let w = Widget::row()
.with_id("toolbar")
.with_theme_style("toolbar")
.with_visual(VisualStyle {
background: Some(Color::rgb(0.1, 0.1, 0.1)),
font: Some(FontRef::bold("Inter")),
..VisualStyle::EMPTY
})
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_theme_style("button"));
let text = w.to_ron().unwrap();
let decoded = Widget::from_ron(&text).unwrap();
assert_eq!(w, decoded);
}
#[test]
fn default_widget_serializes_without_new_fields() {
// The new `visual` and `theme_style` fields skip when empty/None, so
// a piece-1 default widget should still serialize to the piece-1
// form (no `visual:` or `theme_style:` keys in the output).
let w = Widget::default();
let text = w.to_ron().unwrap();
assert!(!text.contains("visual:"));
assert!(!text.contains("theme_style:"));
// And re-parsing yields the same value.
assert_eq!(Widget::from_ron(&text).unwrap(), w);
}
#[test]
fn find_by_id_walks_the_subtree() {
let tree = Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ONE).with_id("a"))
.with_child(
Widget::column()
.with_id("group")
.with_child(Widget::leaf(Vec2::ONE).with_id("buried")),
);
assert_eq!(tree.find_by_id(&"root".into()).unwrap().id.as_str(), "root");
assert_eq!(tree.find_by_id(&"a".into()).unwrap().id.as_str(), "a");
assert_eq!(
tree.find_by_id(&"buried".into()).unwrap().id.as_str(),
"buried"
);
assert!(tree.find_by_id(&"missing".into()).is_none());
// Empty id is never a match.
assert!(tree.find_by_id(&WidgetId::default()).is_none());
}
#[test]
fn set_value_updates_a_descendant() {
let mut tree = Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ONE).with_id("volume"))
.with_child(Widget::leaf(Vec2::ONE).with_id("invert_y"));
assert!(tree.set_value(&"volume".into(), 0.75_f32));
assert!(tree.set_value(&"invert_y".into(), true));
assert_eq!(
tree.value(&"volume".into()).and_then(|v| v.as_float()),
Some(0.75_f32 as f64)
);
assert_eq!(
tree.value(&"invert_y".into()).and_then(|v| v.as_bool()),
Some(true)
);
// Unknown id: returns false, tree unchanged.
assert!(!tree.set_value(&"missing".into(), 0.0_f32));
}
#[test]
fn with_value_builder_sets_value() {
let w = Widget::leaf(Vec2::ONE).with_id("checkbox").with_value(true);
assert_eq!(w.value.as_ref().unwrap().as_bool(), Some(true));
}
#[test]
fn value_round_trips_through_widget_ron() {
use super::super::value::WidgetValue;
let w = Widget::leaf(Vec2::ONE)
.with_id("slider")
.with_value(WidgetValue::Float(0.42));
let text = w.to_ron().unwrap();
let decoded = Widget::from_ron(&text).unwrap();
assert_eq!(w, decoded);
}
}
+477
View File
@@ -0,0 +1,477 @@
//! File-watcher foundation.
//!
//! Watches directories — typically a [`Project`](crate::project::Project)'s
//! `assets/`, `scenes/`, and `scripts/` folders — and emits **debounced**,
//! **deduplicated** change events. Built on the `notify` crate.
//!
//! ## Why debounce
//!
//! Filesystem events are noisy: editors write files in several syscalls (write,
//! rename, chmod), platforms report different fine-grained events for the same
//! logical change, and recursive watches can re-emit while a directory is being
//! populated. Forwarding every raw event to a reloader would re-parse assets
//! many times for one user save. The watcher collapses bursts on each path into
//! one event emitted after the path has been **quiet** for a configurable
//! window.
//!
//! ## Layered design (testability)
//!
//! The debounce/coalesce logic lives in a **pure** [`Debouncer`] that takes
//! `Instant`s from the caller, so unit tests verify it without touching real
//! files or sleeping. [`FileWatcher`] wraps `notify` plus a worker thread that
//! drives the debouncer with real time and forwards settled events through an
//! mpsc channel. A tolerant integration smoke test covers the wiring.
//!
//! ## Asset reload wiring
//!
//! [`reload_changed_assets`] rereads any cached assets whose source path
//! changed via [`AssetServer::reload_path`]. This is the groundwork for
//! Stage-10 script hot-reload — same pattern, different reloader.
//!
//! ```no_run
//! use std::time::Duration;
//! use oxide_engine::watch::{FileWatcher, reload_changed_assets};
//! use oxide_engine::asset::AssetServer;
//!
//! let assets = AssetServer::new();
//! let (mut watcher, events) = FileWatcher::new(Duration::from_millis(150))?;
//! watcher.watch("path/to/project/assets")?;
//!
//! // Pump in the editor's per-frame tick:
//! while let Ok(event) = events.try_recv() {
//! reload_changed_assets(&assets, std::iter::once(event));
//! }
//! # Ok::<(), oxide_engine::watch::WatchError>(())
//! ```
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use crate::asset::AssetServer;
/// Coarse classification of a filesystem change.
///
/// The fine-grained `notify::EventKind` variants are collapsed into three
/// outcomes because every consumer downstream — asset reload, script reload,
/// project-panel refresh — only needs to know "rerun the loader", "drop the
/// entry", or "treat as new". Distinguishing a rename's two legs or an attr
/// change from a content write does not change what to do.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ChangeKind {
/// A file or directory appeared at this path.
Created,
/// An existing file's contents (or a directory's set of children) changed.
Modified,
/// A file or directory was removed at this path.
Removed,
}
/// One settled change event for a single path.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ChangeEvent {
/// The path that changed (absolute when the underlying backend reports it
/// as such — `notify` typically does on the platforms Oxide targets).
pub path: PathBuf,
/// What kind of change it was, after coalescing.
pub kind: ChangeKind,
}
/// Errors from the file-watcher subsystem.
#[derive(Debug, thiserror::Error)]
pub enum WatchError {
/// The underlying `notify` backend failed (no inotify slots, path missing,
/// permission denied, …).
#[error("file-watcher backend error: {0}")]
Backend(#[from] notify::Error),
}
/// Pure debounce-and-coalesce core.
///
/// Holds the most recent change kind seen for each path plus the time it was
/// last touched. [`drain_ready`](Self::drain_ready) emits an event for every
/// path that has been quiet for at least `quiet_window` relative to a
/// caller-supplied `now`. Because the caller controls `now`, tests can drive
/// the debouncer through a deterministic timeline.
pub struct Debouncer {
quiet_window: Duration,
pending: HashMap<PathBuf, (ChangeKind, Instant)>,
}
impl Debouncer {
/// A debouncer that emits a path's event once it has been quiet for at
/// least `quiet_window`.
pub fn new(quiet_window: Duration) -> Self {
Self {
quiet_window,
pending: HashMap::new(),
}
}
/// The configured quiet window.
pub fn quiet_window(&self) -> Duration {
self.quiet_window
}
/// Number of paths currently in the pending set.
pub fn pending_len(&self) -> usize {
self.pending.len()
}
/// Records a raw change for `path` observed at `now`.
///
/// Coalescing rules (chosen to match what a downstream reloader cares
/// about):
/// - `Created` then `Modified` → `Created` (still a fresh file overall).
/// - `Removed` then `Modified` → `Created` (a file came back at this path).
/// - Otherwise the newer kind wins, including `Removed` superseding any
/// prior `Created`/`Modified`.
pub fn record(&mut self, path: PathBuf, kind: ChangeKind, now: Instant) {
let promoted = match self.pending.get(&path).map(|(k, _)| *k) {
Some(ChangeKind::Created) if kind == ChangeKind::Modified => ChangeKind::Created,
Some(ChangeKind::Removed) if kind == ChangeKind::Modified => ChangeKind::Created,
_ => kind,
};
self.pending.insert(path, (promoted, now));
}
/// Removes and returns every event whose last update is at least
/// `quiet_window` old relative to `now`. The returned vector is sorted by
/// path so output is deterministic for testing and snapshotting.
pub fn drain_ready(&mut self, now: Instant) -> Vec<ChangeEvent> {
let mut ready: Vec<ChangeEvent> = Vec::new();
self.pending.retain(|path, (kind, t)| {
if now.saturating_duration_since(*t) >= self.quiet_window {
ready.push(ChangeEvent {
path: path.clone(),
kind: *kind,
});
false
} else {
true
}
});
ready.sort_by(|a, b| a.path.cmp(&b.path));
ready
}
}
/// A directory watcher that emits debounced [`ChangeEvent`]s.
///
/// Construction returns the watcher plus the [`Receiver`] events arrive on.
/// Add directories with [`watch`](Self::watch); remove them with
/// [`unwatch`](Self::unwatch). Dropping the watcher stops the worker thread
/// and disconnects the receiver.
pub struct FileWatcher {
/// Kept alive so its `Drop` releases the backend's OS watches.
_backend: RecommendedWatcher,
debouncer: Arc<Mutex<Debouncer>>,
stop: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl FileWatcher {
/// Creates a watcher whose worker forwards settled events through the
/// returned receiver. Paths are not watched until you call
/// [`watch`](Self::watch).
pub fn new(quiet_window: Duration) -> Result<(Self, Receiver<ChangeEvent>), WatchError> {
let (raw_tx, raw_rx) = channel::<notify::Result<notify::Event>>();
let backend = RecommendedWatcher::new(
move |res| {
// If the receiving end is gone we are tearing down; nothing to
// do but drop the event.
let _ = raw_tx.send(res);
},
notify::Config::default(),
)?;
let debouncer = Arc::new(Mutex::new(Debouncer::new(quiet_window)));
let stop = Arc::new(AtomicBool::new(false));
let (out_tx, out_rx) = channel::<ChangeEvent>();
// Drain the backend frequently enough that bursts settle within a few
// ticks; quarter of the quiet window is short enough to be responsive
// without busy-waiting.
let tick = (quiet_window / 4).max(Duration::from_millis(10));
let worker_debouncer = debouncer.clone();
let worker_stop = stop.clone();
let worker = std::thread::spawn(move || {
while !worker_stop.load(Ordering::Relaxed) {
match raw_rx.recv_timeout(tick) {
Ok(Ok(event)) => {
if let Some(kind) = classify(&event.kind) {
let now = Instant::now();
let mut d = worker_debouncer.lock().unwrap();
for path in event.paths {
d.record(path, kind, now);
}
}
}
// Backend reported an error event; ignore but keep running.
Ok(Err(_)) => {}
// Tick elapsed with no new events. Fall through to drain.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
// Raw channel disconnected → backend dropped → we're done.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
let ready = worker_debouncer.lock().unwrap().drain_ready(Instant::now());
for ev in ready {
if out_tx.send(ev).is_err() {
return;
}
}
}
});
Ok((
Self {
_backend: backend,
debouncer,
stop,
worker: Some(worker),
},
out_rx,
))
}
/// Recursively watches `path`. Repeated calls with the same path are
/// equivalent to one call.
pub fn watch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
self._backend
.watch(path.as_ref(), RecursiveMode::Recursive)?;
Ok(())
}
/// Stops watching `path`. Errors if the backend was not watching it.
pub fn unwatch(&mut self, path: impl AsRef<Path>) -> Result<(), WatchError> {
self._backend.unwatch(path.as_ref())?;
Ok(())
}
/// Read-only snapshot of how many paths are currently buffered by the
/// debouncer (haven't yet been quiet long enough to fire). Mostly for
/// tests and diagnostics.
pub fn pending_count(&self) -> usize {
self.debouncer.lock().unwrap().pending_len()
}
}
impl Drop for FileWatcher {
fn drop(&mut self) {
// Signal first so the worker exits its next loop iteration; dropping
// the backend closes the raw channel as a secondary safety net.
self.stop.store(true, Ordering::Relaxed);
if let Some(h) = self.worker.take() {
let _ = h.join();
}
}
}
/// Translates a `notify` event kind into our coarse [`ChangeKind`]. Returns
/// `None` for events we deliberately ignore (e.g. access timestamps).
fn classify(kind: &EventKind) -> Option<ChangeKind> {
match kind {
EventKind::Create(_) => Some(ChangeKind::Created),
EventKind::Modify(_) => Some(ChangeKind::Modified),
EventKind::Remove(_) => Some(ChangeKind::Removed),
// Reads/opens don't change the file; skipping keeps the event stream
// focused on "something to reload".
EventKind::Access(_) => None,
// `Any` is the fallback some backends emit for "something happened";
// treat as Modified so a reloader still gets a chance.
EventKind::Any => Some(ChangeKind::Modified),
EventKind::Other => None,
}
}
/// Reruns the loader for every cached asset whose source path appears in
/// `events` with a `Created` or `Modified` kind.
///
/// Returns the total number of asset entries reloaded. Paths that are not
/// currently cached (no live handle) are silently ignored — there is nothing
/// to reload, and the next [`AssetServer::load`] will pick up the new contents
/// anyway. `Removed` events are ignored here too: the engine does not
/// preemptively invalidate handles when the underlying file disappears,
/// because gameplay code may want the last-loaded copy to keep working.
pub fn reload_changed_assets<I>(server: &AssetServer, events: I) -> usize
where
I: IntoIterator<Item = ChangeEvent>,
{
let mut n = 0;
for ev in events {
if matches!(ev.kind, ChangeKind::Created | ChangeKind::Modified) {
n += server.reload_path(&ev.path);
}
}
n
}
#[cfg(test)]
mod tests {
use super::*;
fn p(name: &str) -> PathBuf {
PathBuf::from(name)
}
#[test]
fn dedupes_a_burst_for_one_path() {
let mut d = Debouncer::new(Duration::from_millis(100));
let t0 = Instant::now();
d.record(p("a"), ChangeKind::Modified, t0);
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(10));
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(30));
// Path is still "hot" — nothing should fire yet.
assert!(d.drain_ready(t0 + Duration::from_millis(80)).is_empty());
// After the quiet window elapses since the last touch, one event fires.
let ready = d.drain_ready(t0 + Duration::from_millis(130) + Duration::from_millis(10));
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].path, p("a"));
assert_eq!(ready[0].kind, ChangeKind::Modified);
// And the pending set is empty afterwards.
assert_eq!(d.pending_len(), 0);
}
#[test]
fn each_path_settles_independently() {
let mut d = Debouncer::new(Duration::from_millis(50));
let t0 = Instant::now();
d.record(p("a"), ChangeKind::Modified, t0);
d.record(p("b"), ChangeKind::Created, t0 + Duration::from_millis(30));
// At t0+60: "a" is quiet for 60ms (≥ 50ms) but "b" is only 30ms quiet.
let ready = d.drain_ready(t0 + Duration::from_millis(60));
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].path, p("a"));
assert_eq!(d.pending_len(), 1);
// At t0+90: "b" has been quiet for 60ms and now fires.
let ready = d.drain_ready(t0 + Duration::from_millis(90));
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].path, p("b"));
assert_eq!(ready[0].kind, ChangeKind::Created);
}
#[test]
fn drain_output_is_sorted_by_path() {
let mut d = Debouncer::new(Duration::from_millis(10));
let t0 = Instant::now();
d.record(p("zeta"), ChangeKind::Modified, t0);
d.record(p("alpha"), ChangeKind::Modified, t0);
d.record(p("mid"), ChangeKind::Modified, t0);
let ready = d.drain_ready(t0 + Duration::from_millis(20));
assert_eq!(
ready.iter().map(|e| e.path.clone()).collect::<Vec<_>>(),
vec![p("alpha"), p("mid"), p("zeta")]
);
}
#[test]
fn created_then_modified_stays_created() {
let mut d = Debouncer::new(Duration::from_millis(10));
let t0 = Instant::now();
d.record(p("a"), ChangeKind::Created, t0);
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
let ready = d.drain_ready(t0 + Duration::from_millis(20));
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].kind, ChangeKind::Created);
}
#[test]
fn removed_then_modified_becomes_created() {
// A file is deleted, then a new file appears at the same path (e.g.
// editors that save by atomic-replace). Downstream wants to treat this
// as a fresh asset, not a missing one.
let mut d = Debouncer::new(Duration::from_millis(10));
let t0 = Instant::now();
d.record(p("a"), ChangeKind::Removed, t0);
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(2));
let ready = d.drain_ready(t0 + Duration::from_millis(20));
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].kind, ChangeKind::Created);
}
#[test]
fn removed_supersedes_prior_kinds() {
let mut d = Debouncer::new(Duration::from_millis(10));
let t0 = Instant::now();
d.record(p("a"), ChangeKind::Created, t0);
d.record(p("a"), ChangeKind::Modified, t0 + Duration::from_millis(1));
d.record(p("a"), ChangeKind::Removed, t0 + Duration::from_millis(2));
let ready = d.drain_ready(t0 + Duration::from_millis(20));
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].kind, ChangeKind::Removed);
}
#[test]
fn classify_covers_the_three_main_kinds() {
use notify::event::{CreateKind, ModifyKind, RemoveKind};
assert_eq!(
classify(&EventKind::Create(CreateKind::File)),
Some(ChangeKind::Created)
);
assert_eq!(
classify(&EventKind::Modify(ModifyKind::Any)),
Some(ChangeKind::Modified)
);
assert_eq!(
classify(&EventKind::Remove(RemoveKind::File)),
Some(ChangeKind::Removed)
);
assert_eq!(classify(&EventKind::Any), Some(ChangeKind::Modified));
}
/// Tolerant smoke test: write a file under a temp dir, then poll for an
/// event with a generous timeout. The unit tests above already cover the
/// debounce logic deterministically, so this only needs to prove the
/// notify→debouncer→channel wiring is connected.
#[test]
fn end_to_end_emits_on_real_filesystem_change() {
let mut dir = std::env::temp_dir();
dir.push(format!("oxide_watch_smoke_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (mut watcher, events) =
FileWatcher::new(Duration::from_millis(80)).expect("create watcher");
watcher.watch(&dir).expect("watch tempdir");
// Some platforms need a brief moment between watch() and producing
// events for fresh writes; the deadline below absorbs that.
let file = dir.join("hello.txt");
std::fs::write(&file, "first").unwrap();
// Touch a couple more times to exercise dedup under real timing.
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&file, "second").unwrap();
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&file, "third").unwrap();
// Wait up to 3 seconds for at least one event for our file. This is
// intentionally generous: CI machines under load and macOS FSEvents
// can take a second or more to deliver the first event.
let deadline = Instant::now() + Duration::from_secs(3);
let mut saw = None;
while Instant::now() < deadline {
if let Ok(ev) = events.recv_timeout(Duration::from_millis(100)) {
// Some backends report a canonicalized path; compare by file
// name to stay robust to that.
if ev.path.file_name() == Some(std::ffi::OsStr::new("hello.txt")) {
saw = Some(ev);
break;
}
}
}
std::fs::remove_dir_all(&dir).ok();
if saw.is_none() {
// Some sandboxes (containerized CI) disable filesystem-event
// backends entirely; skip rather than fail flakily there.
eprintln!("SKIP: no inotify/FSEvent backend appears to deliver events here");
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//! The [`WindowApp`] trait and per-callback context.
use winit::event::WindowEvent;
use winit::window::Window;
use crate::input::InputState;
use crate::math::Color;
use crate::render::{Gpu, RenderContext};
/// An application driven by the engine's event loop.
///
/// Implement this and pass the value to [`run`](super::run). All methods have
/// empty defaults so minimal apps only override what they need. Per frame the
/// engine calls [`event`](Self::event) for each pending window event, then
/// [`update`](Self::update), then clears and presents the surface.
///
/// This trait is the **window-event handler** — the per-frame plumbing between
/// `winit` and a renderer. It is distinct from the engine's
/// [`App`](crate::app::App) **container**, which owns the scene, assets, and
/// scheduled systems. The editor's main loop typically implements this trait
/// on a struct that *also* owns an `oxide_engine::app::App`.
pub trait WindowApp {
/// Called once, after the window and GPU context exist but before the
/// first frame.
fn init(&mut self, ctx: &mut AppCtx<'_>) {
let _ = ctx;
}
/// Called for every raw window event (keyboard, mouse, resize, focus, …).
///
/// Events the engine itself reacts to (close request, resize) are still
/// forwarded here afterwards, so apps observe everything.
fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
let _ = (ctx, event);
}
/// Called once per frame, before the frame is rendered.
fn update(&mut self, ctx: &mut AppCtx<'_>) {
let _ = ctx;
}
/// Called each frame after the surface has been cleared and before it is
/// presented, so the app can record its own draw commands into the frame.
///
/// This is the hook editor/overlay UI (egui) and, in later stages, the
/// scene renderer draw through. The surface is cleared with
/// [`LoadOp::Clear`](wgpu::LoadOp::Clear) *before* this runs; record passes
/// here with [`LoadOp::Load`](wgpu::LoadOp::Load) to draw on top of the
/// clear color rather than wiping it.
fn render(&mut self, ctx: &RenderCtx<'_>) {
let _ = ctx;
}
}
/// Per-frame rendering context passed to [`WindowApp::render`].
///
/// Unlike [`AppCtx`], this borrows the GPU and surface immutably: by the time
/// the draw hook runs the frame's surface texture is already acquired, so the
/// app receives the handles it needs to record additional passes into
/// [`view`](Self::view) without re-entering the render context.
pub struct RenderCtx<'a> {
/// The GPU device/queue to record and submit commands with.
pub gpu: &'a Gpu,
/// The current frame's surface texture view (the render target).
pub view: &'a wgpu::TextureView,
/// The window being rendered, e.g. for input/UI integration that needs it.
pub window: &'a Window,
/// The surface's texture format, needed to build matching pipelines.
pub surface_format: wgpu::TextureFormat,
/// Surface size in physical pixels (`width`, `height`).
pub size: (u32, u32),
}
/// Engine state handed to every [`WindowApp`] callback.
pub struct AppCtx<'a> {
pub(crate) render: &'a mut RenderContext,
pub(crate) window: &'a Window,
pub(crate) exit: &'a mut bool,
pub(crate) input: &'a InputState,
/// Seconds elapsed since the previous frame (`0.0` during
/// [`App::init`] and the first frame).
pub dt: f32,
}
impl AppCtx<'_> {
/// The render context driving the window surface.
pub fn render(&mut self) -> &mut RenderContext {
self.render
}
/// Sets the color the surface is cleared to, effective next frame.
pub fn set_clear_color(&mut self, color: Color) {
self.render.set_clear_color(color);
}
/// The current clear color.
pub fn clear_color(&self) -> Color {
self.render.clear_color()
}
/// Current surface size in physical pixels.
pub fn size(&self) -> (u32, u32) {
self.render.size()
}
/// Sets the window title.
pub fn set_title(&self, title: &str) {
self.window.set_title(title);
}
/// The window being driven, e.g. to construct UI/input integration that
/// needs a window handle.
pub fn window(&self) -> &Window {
self.window
}
/// Asks the event loop to exit after the current callback returns.
pub fn request_exit(&mut self) {
*self.exit = true;
}
/// The per-frame input snapshot.
///
/// Reflects every keyboard / mouse / scroll event delivered since the
/// previous frame's `update` returned. In [`WindowApp::event`] callbacks
/// it includes the event currently being delivered (the runner pumps it
/// before invoking the callback). In [`WindowApp::update`] it is the
/// accumulated state for the new frame; the runner clears edges (pressed/
/// released, mouse delta, scroll) automatically after `update` returns.
pub fn input(&self) -> &InputState {
self.input
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Windowing and the application event loop.
//!
//! Stage 2 scope: open a window via `winit`, hand its surface to the
//! [`render`](crate::render) module, and run a clear-color render loop.
//! Applications implement [`WindowApp`] and are driven by [`run`]; raw window
//! events (keyboard, mouse, resize, …) are forwarded to
//! [`WindowApp::event`] untranslated — input abstraction arrives in Stage 5.
//!
//! Stage 6 renamed this trait from `App` to `WindowApp` so the engine's core
//! [`App`](crate::app::App) container — the owner of scene, assets, and
//! scheduled systems — can live in the prelude unambiguously. The two are
//! distinct roles: this trait is the **window-event handler** the editor and
//! examples implement; the core `App` is the engine state they typically wrap
//! around.
mod app;
mod runner;
pub use app::{AppCtx, RenderCtx, WindowApp};
pub use runner::run;
pub mod event {
//! Raw window/input event types, re-exported from `winit`.
//!
//! Stage 2 deliberately exposes events untranslated; the Stage 5 input
//! system will layer action mapping on top of these.
pub use winit::dpi::{PhysicalPosition, PhysicalSize};
pub use winit::event::{
DeviceEvent, DeviceId, ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta,
WindowEvent,
};
pub use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
}
use crate::math::Color;
/// Initial window settings, consumed by [`run`].
#[derive(Debug, Clone)]
pub struct WindowConfig {
/// Window title.
pub title: String,
/// Initial inner width in logical pixels.
pub width: u32,
/// Initial inner height in logical pixels.
pub height: u32,
/// Whether the user can resize the window.
pub resizable: bool,
/// Color the surface is cleared to each frame (changeable at runtime via
/// [`AppCtx::set_clear_color`]).
pub clear_color: Color,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
title: "Oxide".to_string(),
width: 1280,
height: 720,
resizable: true,
clear_color: Color::BLACK,
}
}
}
+175
View File
@@ -0,0 +1,175 @@
//! The winit event-loop runner behind [`run`].
use std::sync::Arc;
use std::time::Instant;
use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::{Window, WindowId};
use super::{AppCtx, WindowApp, WindowConfig};
use crate::input::InputState;
use crate::render::RenderContext;
/// Opens a window per `config` and runs `app` until it requests exit or the
/// window is closed.
///
/// Blocks the calling thread for the lifetime of the window (an OS
/// requirement: the event loop must run on the main thread).
pub fn run<A: WindowApp>(config: WindowConfig, app: A) -> anyhow::Result<()> {
let event_loop = EventLoop::new()?;
// Poll: render continuously (a game loop), rather than waiting for
// input events like a desktop utility would.
event_loop.set_control_flow(ControlFlow::Poll);
let mut runner = Runner {
config,
app,
state: None,
input: InputState::new(),
last_frame: None,
exit: false,
error: None,
};
event_loop.run_app(&mut runner)?;
match runner.error {
Some(err) => Err(err),
None => Ok(()),
}
}
struct WindowState {
window: Arc<Window>,
render: RenderContext,
}
struct Runner<A: WindowApp> {
config: WindowConfig,
app: A,
state: Option<WindowState>,
/// Accumulated keyboard/mouse/scroll state across the current frame;
/// pumped from every window event and cleared after `update` returns.
input: InputState,
last_frame: Option<Instant>,
exit: bool,
/// Initialization/render errors are stashed here and returned from
/// [`run`], since winit callbacks cannot propagate `Result`.
error: Option<anyhow::Error>,
}
impl<A: WindowApp> Runner<A> {
fn create_window(&mut self, event_loop: &ActiveEventLoop) -> anyhow::Result<()> {
let attrs = Window::default_attributes()
.with_title(&self.config.title)
.with_inner_size(LogicalSize::new(self.config.width, self.config.height))
.with_resizable(self.config.resizable);
let window = Arc::new(event_loop.create_window(attrs)?);
let mut render = RenderContext::new(window.clone())?;
render.set_clear_color(self.config.clear_color);
let mut state = WindowState { window, render };
let mut ctx = AppCtx {
render: &mut state.render,
window: &state.window,
exit: &mut self.exit,
input: &self.input,
dt: 0.0,
};
self.app.init(&mut ctx);
self.state = Some(state);
Ok(())
}
fn fail(&mut self, event_loop: &ActiveEventLoop, err: anyhow::Error) {
self.error = Some(err);
event_loop.exit();
}
}
impl<A: WindowApp> ApplicationHandler for Runner<A> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
// On desktop `resumed` fires once at startup; the suspend/resume
// cycle only matters on mobile, which Oxide does not target yet.
if self.state.is_none() {
if let Err(err) = self.create_window(event_loop) {
self.fail(event_loop, err);
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
let Some(state) = self.state.as_mut() else {
return;
};
// Fold the event into the per-frame input snapshot before any callback
// sees it, so `ctx.input()` is always up-to-date for the receiver.
// `handle_event` is a no-op for non-input events (resize, redraw, …).
self.input.handle_event(&event);
// Engine-level handling first…
match &event {
WindowEvent::CloseRequested => self.exit = true,
WindowEvent::Resized(size) => state.render.resize(size.width, size.height),
WindowEvent::RedrawRequested => {
let now = Instant::now();
let dt = self
.last_frame
.map_or(0.0, |last| (now - last).as_secs_f32());
self.last_frame = Some(now);
{
let mut ctx = AppCtx {
render: &mut state.render,
window: &state.window,
exit: &mut self.exit,
input: &self.input,
dt,
};
self.app.update(&mut ctx);
}
// Render the frame, letting the app draw its own passes (e.g.
// editor UI) into the cleared surface via `App::render`.
let app = &mut self.app;
let result = state
.render
.render_frame_with(&state.window, |rcx| app.render(rcx));
if let Err(err) = result {
self.fail(event_loop, err.into());
return;
}
// Roll edges/deltas off so the next frame starts clean.
// Held state and cursor anchor persist by design.
self.input.end_frame();
}
_ => {}
}
// …then forward every event raw to the app (including the ones
// handled above, so apps can observe resizes, close requests, etc.).
if !matches!(event, WindowEvent::RedrawRequested) {
let mut ctx = AppCtx {
render: &mut state.render,
window: &state.window,
exit: &mut self.exit,
input: &self.input,
dt: 0.0,
};
self.app.event(&mut ctx, &event);
}
if self.exit {
event_loop.exit();
}
}
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
// Continuous rendering: request the next frame as soon as the event
// queue drains.
if let Some(state) = &self.state {
state.window.request_redraw();
}
}
}