Files
Oxide/docs/scene.md
T
Homer Simpson 9eead719b0 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>
2026-07-05 20:41:02 +02:00

7.6 KiB

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::Entity handle — a small Copy id.
  • Every entity created through the scene carries a Node (name + enabled flag) and a local Transform.
  • 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/Children as components is idiomatic but makes ordered iteration and reparenting awkward (archetype moves, borrow juggling) and gives no ordering guarantee. Keeping the tree in the Scene yields deterministic child order — which serialization and the editor both rely on — and O(1) link edits. The ECS still owns all entity data.
  • World transforms are resolved, not stored. There is no dirty-flag cache yet; world_transforms() recomputes in one pass. A cache can be added later behind the same API without changing callers.
  • Despawn policies map onto the two things callers actually want: delete a whole subtree, or remove one node and keep its children.