Scene Graph & Entity System
The oxide_engine::scene module is the world model every later system plugs
into. It pairs a lightweight ECS (hecs) with a
parent/child Transform hierarchy, so entities can hold arbitrary
components and live in a spatial tree.
This document is the usage reference for the module as delivered in Stage 3. For the math types it builds on, see math.md; for coordinate and units conventions, see conventions.md.
Importing
use oxide_engine::scene::{Scene, Node, Entity, DespawnPolicy, SceneError};
// or, for the common types, via the prelude:
use oxide_engine::prelude::*; // Scene, Node, Entity, DespawnPolicy, SceneError, Transform, …
The full ECS is re-exported as oxide_engine::hecs so you share one copy of
Entity and the query API with the engine.
Mental model
- An entity is a
hecs::Entityhandle — a smallCopyid. - Every entity created through the scene carries a
Node(name + enabled flag) and a localTransform. - The hierarchy (which entity parents which) is owned by the
Scene, not stored as components. This keeps child ordering deterministic and makes reparenting cheap. - A local transform is what you author. A world transform is the local
composed with every ancestor:
world = parent_world * local. The scene resolves these on demand; it does not cache them.
| Type | Role |
|---|---|
Scene |
Owns entities + hierarchy; spawn, despawn, reparent, query, resolve transforms |
Node |
Per-entity metadata: name, enabled |
DespawnPolicy |
Whether despawn takes the subtree or detaches children |
SceneError |
Reparent / (de)serialization failures |
Scene
Building a hierarchy
use oxide_engine::prelude::*;
let mut scene = Scene::new();
// A root entity (no parent).
let sun = scene.spawn("sun", Transform::IDENTITY);
// Children. `spawn_child` panics if the parent is not a live entity.
let planet = scene.spawn_child(
sun,
"planet",
Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)),
);
let moon = scene.spawn_child(
planet,
"moon",
Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)),
);
spawn/spawn_child take anything that converts into a Node, so a bare
&str works as a name ("planet" ≡ Node::new("planet")); pass a Node
directly when you need to set enabled.
Resolving world transforms
// One entity (walks up the parent chain):
let moon_world = scene.world_transform(moon).unwrap();
// Every entity at once (single top-down pass — prefer this in bulk):
let worlds = scene.world_transforms(); // HashMap<Entity, Transform>
world_transforms() is the path the renderer will use; it resolves a
10,000-entity, 5-level scene in well under a millisecond (see the
world_transforms_10k_depth5 benchmark).
Reparenting
scene.set_parent(moon, Some(sun))?; // moon now orbits the sun directly
scene.set_parent(moon, None)?; // moon becomes a root
Reparenting preserves the local transform (it does not compensate to keep
the world transform fixed). Cycles are rejected: parenting an entity to itself
or to one of its descendants returns [SceneError::WouldCycle], leaving the
hierarchy untouched.
Despawning
use oxide_engine::scene::DespawnPolicy;
// Remove the entity and its entire subtree:
scene.despawn(planet, DespawnPolicy::Recursive);
// Remove only the entity; its children move up to its parent (or become roots
// if it was a root):
scene.despawn(planet, DespawnPolicy::DetachChildren);
Editing nodes
scene.set_name(planet, "earth");
scene.set_enabled(moon, false); // later systems skip disabled subtrees
scene.set_local_transform(moon, Transform::IDENTITY);
let name = scene.name(planet); // Option<String>
let on = scene.is_enabled(moon); // Option<bool>
let local = scene.local_transform(moon); // Option<Transform>
Querying
for &root in scene.roots() { /* … */ }
for &child in scene.children(planet) { /* … */ }
let parent = scene.parent(moon); // Option<Entity>
let n = scene.len();
Extra components (it's a real ECS)
Entities are full hecs entities, so later stages attach their own components
(meshes, rigid bodies, …) alongside the Node/Transform:
scene.world_mut().insert_one(planet, /* e.g. */ 0u32).unwrap();
let value = scene.get::<u32>(planet); // Option<hecs::Ref<u32>>
Use world() for read-only queries and world_mut() for adding/removing
non-hierarchy components. Drive lifecycle and parenting through the Scene
methods so the hierarchy bookkeeping stays consistent — spawning or despawning
directly on the world bypasses it.
Node
pub struct Node {
pub name: String, // display name; not required to be unique
pub enabled: bool, // honored by later systems, not by transform resolution
}
Node::new(name) builds an enabled node. enabled is a declaration of intent:
Stage 3 only stores and toggles it; rendering/physics/audio will skip disabled
subtrees in later stages. It deliberately does not affect
world_transform, which is purely geometric.
Serialization
A scene round-trips through RON. Because hecs::Entity handles are not stable
across a save/load, the scene is flattened to an indexed node list in a
deterministic pre-order walk, so serialize → deserialize → serialize is
byte-for-byte stable.
let ron: String = scene.to_ron()?;
let restored = Scene::from_ron(&ron)?;
assert_eq!(ron, restored.to_ron()?); // identical
Corrupt input (out-of-range child indices, a node listed as both root and
child) is rejected with [SceneError::Deserialize].
Errors
pub enum SceneError {
NoSuchEntity, // operation referenced a dead entity
WouldCycle, // reparent would make an entity its own ancestor
Serialize(String), // encoding to RON failed
Deserialize(String), // decoding failed or data was inconsistent
}
Example
A complete, runnable tour lives in
examples/src/bin/scene_basic.rs:
cargo run -p oxide-examples --bin scene_basic
It builds a sun/planet/moon hierarchy, prints local vs. world transforms, reparents a node, round-trips through RON, and despawns with a detach policy.
In the editor
oxide-editor renders a Scene Hierarchy panel (left) and an Inspector
(right) over the viewport, driving this same API: select a node, rename it,
toggle its enabled flag, reparent it via the Inspector's parent dropdown, and
add/delete nodes from the toolbar. The egui integration is editor-only — the
engine exposes a generic post-clear draw hook (App::render) and keeps egui
out of its own dependency tree.
Design notes
- Why hierarchy outside the ECS? Storing
Parent/Childrenas components is idiomatic but makes ordered iteration and reparenting awkward (archetype moves, borrow juggling) and gives no ordering guarantee. Keeping the tree in theSceneyields deterministic child order — which serialization and the editor both rely on — and O(1) link edits. The ECS still owns all entity data. - World transforms are resolved, not stored. There is no dirty-flag cache
yet;
world_transforms()recomputes in one pass. A cache can be added later behind the same API without changing callers. - Despawn policies map onto the two things callers actually want: delete a whole subtree, or remove one node and keep its children.