Skip to content

Scene

A Scene is the top-level container for the real-world things you render: Models, Cameras, Lights (directional, point, and spot are all one type), and any custom SceneObject. It owns one or more Passes underneath and implements Renderable, so you hand the whole scene to the Renderer in a single call.

The split mirrors glTF / USD: a scene is a flat list of nodes (geometry, viewpoints, lights), and the renderer is the orchestrator that walks the scene and produces a frame. The Scene keeps the user-facing API in the real-world layer; the GPU primitives (Pass, Shader, Texture) stay underneath and don’t leak into the call site.

Scene::new() is synchronous and takes no Renderer. The first time a SceneObject is added, the Scene creates a default Pass to hold it. The first time the Scene is rendered, the underlying GPU resources initialise on demand. This is the lazy-init pattern the rest of FragmentColor follows.

A Scene owns one ordered Vec<Pass>. Loaders, builders, and your own code all append into the same list, and the renderer walks it in order. No pass is privileged in render order: the default Pass that scene.add(&model) targets is an ordinary member, slotted in at the point of your first add. After Scene::load the whole graph is in your hands. Read it with list_passes or get_pass, restructure it with add_pass, remove_pass, and set_passes, and configure each pass (load_previous, clear color, viewport, target) the same way you would a hand-built one. That makes a loaded Scene compose onto any other Pass in a frame, instead of clearing whatever it lands on.

A Scene that only carries Models would normally render black: shaders need a camera projection and at least one light for the lighting term to be non-zero. To make the “hello world” path render something recognisable, the Scene injects sensible defaults when the user hasn’t supplied them:

  • Default Camera: Camera::perspective(60°, 1.0, 0.1, 100.0) looking from [0, 0, 5] at the origin with +Y up.
  • Default Light: a Light::directional aimed at [0.0, -0.3, -1.0] with full-white color, providing a forward-tilted fill so a front-facing quad reads as lit rather than silhouetted.

These only fire when no user Camera / Light has been added; as soon as you add your own, your values win. A composition caller that drives every uniform from the host can suppress them with no_defaults (or the per-kind no_default_camera / no_default_light), or swap the stock values out with set_default_camera / set_default_light.


Build a Scene from a serialized 3D file. Pass a path (.gltf or .glb) or in-memory .glb bytes straight to Scene::load. The format is inferred from the input, so there’s nothing extra to name.

Scene::load is synchronous and takes no Renderer. Any textures the parser encounters are queued as pending uploads on the resulting Materials; the renderer drains them on first render, or earlier if you call Renderer::load.

Today’s coverage: static glTF, meaning mesh primitives (POSITION + NORMAL + UV0 + indices), PBR-MR materials with all five texture slots, per-node transforms flattened into Model matrices, glTF camera nodes, and KHR_lights_punctual lights. Animation, skinning, morph targets, and material extensions beyond PBR-MR are out of scope; they parse cleanly and the loader ignores them.

By default the loader instantiates the glTF file’s own camera and light nodes. When you bring your own (a spring-arm rig, a UI-locked overlay camera, an animated key/fill setup) the embedded ones would only fight for the same shader uniforms. Load through gltf(...) to get a builder with cameras(false) and lights(false) toggles:

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Camera, Scene, SceneSource};
// Load geometry + materials only; drop the file's camera and lights.
let scene = Scene::load(
SceneSource::gltf("path/to/model.glb")
.cameras(false)
.lights(false),
)?;
// Supply your own camera; it's now the only one the Scene tracks.
let camera = Camera::perspective(1.047, 16.0 / 9.0, 0.1, 100.0)
.look_at([0.0, 1.5, 4.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
scene.add(&camera)?;
2 collapsed lines
Ok(())
}

Scene::load is fully synchronous: the underlying importer decodes embedded images and buffers on the calling thread. A 100 MB .glb freezes that thread for the duration. Two implications:

  • Native: spawn Scene::load on a worker thread (std::thread::spawn, tokio::task::spawn_blocking, …) if you can’t afford a frame stall.
  • WASM: a sync load on the main thread freezes the page, and the path form isn’t available (the importer goes through std::fs). Fetch the .glb bytes via fetch inside a Web Worker, hand them to Scene::load(bytes), then transfer the produced Scene back.
1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Scene;
// A path — `.gltf` JSON (with external buffers/images) or a `.glb` container.
let scene = Scene::load("path/to/model.gltf")?;
// In-memory `.glb` bytes — fetched from disk, the network, or another
// asset pipeline before this point.
let glb_bytes: Vec<u8> = std::fs::read("path/to/model.glb")?;
let scene2 = Scene::load(glb_bytes)?;
3 collapsed lines
let _ = (scene, scene2);
Ok(())
}

Build an empty Scene. No Renderer argument, no async, nothing to await. The Scene’s GPU resources initialise lazily when it’s first rendered.

The first call to Scene::add creates a default Pass under the hood and routes the object into it. Multiple SceneObjects share that one Pass unless you explicitly add more via Scene::add_pass.

Scene is Clone (shallow Arc-share); cloning gives another handle to the same underlying scene, so mutations on one clone are visible on the other.

use fragmentcolor::Scene;
let scene = Scene::new();
// scene is empty; add Models / Cameras / Lights with `scene.add(...)`.
1 collapsed line
let _ = scene;

Absorb any SceneObject (a Model, Camera, Light, or a user-defined node that implements the trait) into the scene. The Scene routes it onto its default Pass, which is created lazily on the first call.

Each kind brings its own attach behaviour: a Model queues a draw with its own per-instance transform; a Camera or Light wires its uniforms into every shader the pass renders, both the ones already there and the ones added afterwards. Camera and Light hold Arc-shared state, so subsequent mutations (camera.look_at(...), light.set_color(...)) propagate to every shader on the pass with no further add call.

Returns Result<&Scene, PassError> so Models can fail at attach time when the Mesh layout doesn’t match the Material’s shader; Cameras and Lights always succeed. Chain with ? between calls.

If you add Models without adding a Camera or any Light, the Scene injects sensible defaults at render time (Camera::perspective looking from [0, 0, 5], a white Light::directional pointing slightly off-axis) so the first frame renders something recognisable. Add your own to take over.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Camera, Light, Material, Mesh, Model, Renderer, Scene, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let model = Model::new(mesh, Material::pbr());
let camera = Camera::perspective(1.047, 1.0, 0.1, 100.0)
.look_at([0.0, 0.0, 3.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
let sun = Light::directional([0.3, -1.0, -0.4], [1.0, 0.95, 0.9]);
let scene = Scene::new();
scene.add(&model)?;
scene.add(&camera)?;
scene.add(&sun)?;
// Updating the camera later is enough — every shader on the scene picks
// the new view_proj up at the next render.
camera.look_at([3.0, 1.0, 5.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
4 collapsed lines
let _ = renderer;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Add a SceneObject (Model / Camera / Light / custom) to a specific Pass in the graph, addressed by index or by name. It’s the targeted form of add: where add routes into the scene’s default pass, add_to lets you pick the pass yourself, which matters once a scene holds more than one.

Pass an index (matching get_pass) or a name (matching find_pass). An index out of range, or a name that matches nothing, is an error. The Scene-level bookkeeping is identical to add: the object surfaces in models() / cameras() / lights(), and a Camera or Light there still suppresses the matching default-injection.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Pass, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let model = Model::new(mesh, Material::pbr());
let scene = Scene::new();
scene.add_pass(&Pass::new("geometry"));
// Target the pass by name (or pass its index: scene.add_to(0, &model)).
scene.add_to("geometry", &model)?;
3 collapsed lines
assert_eq!(scene.models().len(), 1);
Ok(())
}

Return a snapshot of every Model added to this Scene via Scene::add, including Models the loader instantiated from glTF mesh nodes.

Each entry is an Arc-shared clone of the original handle. Mutating one of them (set_visible, translate, set_transform, …) propagates live to every shader the Model was wired into, no re-attach needed. The returned Vec is the snapshot at call time. Adding more Models after the call doesn’t grow this Vec, but the handles you already have stay live.

Order: insertion order. Loader-produced Models appear in glTF node-walk order; user-added Models appear after.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let model = Model::new(mesh, Material::pbr());
let scene = Scene::new();
scene.add(&model)?;
// LOD switch: hide every model the user just loaded, based on a
// camera-distance heuristic the caller computes elsewhere.
for m in scene.models() {
m.set_visible(false);
}
2 collapsed lines
Ok(())
}

Return a snapshot of every Camera added to this Scene via Scene::add, including Cameras the loader instantiated from glTF camera nodes (unless you skipped them via the camera filter on Scene::load).

Each entry is an Arc-shared clone of the original handle. camera.look_at(...) / camera.set_aspect(...) on a returned handle propagates the new view + projection to every shader the Camera is wired into, the same live semantics as the Camera handle you originally added.

When the Scene rendered with a defaulted Camera (no user-supplied Camera when the first render landed), the auto-injected default appears in this list too, so a consumer who wants to drive the default camera per frame can grab scene.cameras().first() and call look_at on it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Scene;
let scene = Scene::load("path/to/model.glb")?;
// Animate every camera the glTF shipped per frame instead of supplying
// our own. Most scenes carry a single camera, so the loop body usually
// runs once.
for camera in scene.cameras() {
camera.look_at([0.0, 1.5, 4.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
camera.set_aspect(16.0 / 9.0);
}
2 collapsed lines
Ok(())
}

Return a snapshot of every Light added to this Scene via Scene::add, including Lights the loader instantiated from glTF KHR_lights_punctual nodes (unless you skipped them via the light filter on Scene::load).

Each entry is an Arc-shared clone. Mutating a returned handle (set_color, set_intensity, set_position, …) propagates to every shader the Light occupies a slot in.

The default-injected Light (auto-fired when the Scene first renders with no user-supplied Light) appears in this list too, so consumers can grab the default and tweak it instead of supplanting it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Scene;
let scene = Scene::load("path/to/model.glb")?;
// Darken every loaded light to half intensity for a moody pass.
for light in scene.lights() {
let current = light.intensity();
light.set_intensity(current * 0.5);
}
2 collapsed lines
Ok(())
}

Set the scene-wide ambient color. The PBR shader adds albedo * ambient to every fragment regardless of which lights are attached, so unlit faces don’t read pitch-black and lit faces get a subtle warm/cool fill on top of direct lighting.

Defaults: every Material seeded by Material::pbr() starts with ambient = [0.03, 0.03, 0.03] (the dim grey the PBR shader had hardcoded before this knob existed). Calling Scene::ambient overrides that for every shader the scene visits today and for any Models added afterwards: the value is stashed on the Scene and re-stamped onto shaders that join later via Scene::add.

Returns a handle to the same Scene (Arc-shared) for chaining.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Light, Material, Mesh, Model, Renderer, Scene, Vertex};
let renderer = Renderer::new();
let target = renderer.create_texture_target([64, 64]).await?;
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
// Warm dusk ambient — applies to every Material added below.
scene.ambient([0.06, 0.04, 0.03]);
scene.add(&Model::new(mesh, Material::pbr()))?;
scene.add(&Light::directional([0.3, -1.0, -0.4], [1.0, 0.95, 0.9]))?;
renderer.render(&scene, &target)?;
3 collapsed lines
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Append a user-built Pass to the scene’s pass graph. A Scene owns one ordered list of passes, and add_pass pushes onto the end. Order is the order you build it: passes render in vec order, and the default pass that holds scene.add(&model) geometry is an ordinary member of that list, slotted in at the point of your first add.

That makes add_pass the hook for shadow maps, depth pre-passes, screen-space backdrops, and post-effects alike. Insert before your geometry for a backdrop, after it for an overlay. To inspect or reorder the result, reach for list_passes, get_pass, remove_pass, and set_passes.

The Pass is cloned (shallow Arc-share) when stored, so further changes you make to the original handle reach the Scene’s copy too.

Every Pass::new(name) starts with a clear-to-transparent input. The first render of the frame for that Pass wipes its colour attachment to [0, 0, 0, 0]. When you chain passes that read each other’s output, the downstream Pass clears the upstream Pass’s output unless you opt out:

  • Call pass.load_previous() on the downstream Pass to keep the previous contents around (the wgpu LoadOp::Load equivalent).
  • Or call pass.set_clear_color([r, g, b, a]) to choose a specific clear value (still a clear, just a different colour).

Scene::add_pass stores each pass independently. There’s no automatic chaining of their attachments. To share a target between two passes, route them through the same set_target(...) and use load_previous to compose.

1 collapsed line
async fn run() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Pass, Renderer, Scene, Vertex};
let renderer = Renderer::new();
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let model = Model::new(mesh, Material::pbr());
// A backdrop pass that clears to a soft blue before the scene's main draw.
let backdrop = Pass::new("backdrop");
backdrop.set_clear_color([0.05, 0.08, 0.12, 1.0]);
let scene = Scene::new();
scene.add_pass(&backdrop);
scene.add(&model)?;
4 collapsed lines
let _ = renderer;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> { pollster::block_on(run()) }

Remove a Pass from the scene’s pass graph. Identity is by handle: Pass is Clone and Arc-backed, so the handle you pass in is matched against the graph by pointer, not by name. Returns true when a pass left the graph, false when the handle wasn’t there.

If the removed pass was the default pass that Scene::add routes objects into, the Scene forgets it. The next add builds a fresh default pass at the end of the graph.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Pass, Scene};
let scene = Scene::new();
let backdrop = Pass::new("backdrop");
let overlay = Pass::new("overlay");
scene.add_pass(&backdrop);
scene.add_pass(&overlay);
// Drop the backdrop; the overlay stays.
let removed = scene.remove_pass(&backdrop);
4 collapsed lines
assert!(removed);
assert_eq!(scene.list_passes().len(), 1);
Ok(())
}

Read one Pass from the graph by index, in render order. Returns None when the index is out of range.

The returned Pass is an Arc-shared clone of the one the Scene renders. Configuring it (load_previous, set_clear_color, set_target) drives the live pass, no re-insert needed. To borrow the whole graph at once, use Scene::list_passes.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Pass, Scene};
let scene = Scene::new();
scene.add_pass(&Pass::new("backdrop"));
scene.add_pass(&Pass::new("geometry"));
// Fetch the second pass (index 1) to reconfigure it. An out-of-range
// index returns None instead.
let geometry = scene.get_pass(1);
7 collapsed lines
assert!(geometry.is_some());
assert!(scene.get_pass(2).is_none());
if let Some(pass) = geometry {
pass.load_previous();
}
Ok(())
}

Find a Pass in the scene’s graph by name, returning None when nothing matches. Names come from Pass::new (or whatever label a loader assigned), and you can read one back with Pass::name.

This is the name-addressed counterpart to get_pass, which addresses by index. Names aren’t required to be unique; find_pass returns the first match in render order. The returned Pass is an Arc-shared clone, so configuring it drives the pass the Scene renders.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Pass, Scene};
let scene = Scene::new();
scene.add_pass(&Pass::new("backdrop"));
scene.add_pass(&Pass::new("geometry"));
// Look the geometry pass up by name to reconfigure it. A name with no
// match returns None instead.
let geometry = scene.find_pass("geometry");
4 collapsed lines
assert!(geometry.is_some());
assert!(scene.find_pass("missing").is_none());
Ok(())
}

Return a snapshot of every Pass in the scene, in render order. Each entry is an Arc-shared clone, so configuring one (load_previous, set_clear_color, set_viewport, set_target) drives the pass the Scene renders. The returned Vec is the snapshot at call time; appending passes afterward doesn’t grow it.

This is the composition hook. After Scene::load the whole pass graph is in your hands: walk it and call load_previous so the scene composes onto a previous draw instead of clearing it, then hand the Scene to the renderer alongside whatever else the frame needs.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
scene.add(&Model::new(mesh, Material::pbr()))?;
// Compose, don't clear: keep whatever the previous pass drew.
for p in scene.list_passes() {
p.load_previous();
}
2 collapsed lines
Ok(())
}

Replace the scene’s entire pass graph with a new ordered list. The passes render in vec order. This is the sugar for “load, reorder, render” when you’d rather hand over a fresh ordering than call remove_pass and add_pass one at a time.

If the default pass that Scene::add routes objects into survives in the new list, the Scene keeps using it. If you drop it, the Scene forgets it and the next add builds a fresh one at the end.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Pass, Scene};
let scene = Scene::new();
scene.add_pass(&Pass::new("scratch"));
// Swap in a deliberate order: shadow map, then geometry, then overlay.
scene.set_passes(vec![
Pass::new("shadow"),
Pass::new("geometry"),
Pass::new("overlay"),
]);
3 collapsed lines
assert_eq!(scene.list_passes().len(), 3);
Ok(())
}

Turn off both default-Camera and default-Light injection. By default a Scene carrying Models but no Camera or Light injects a stock perspective camera and a white directional light at first render, so the hello-world path shows something recognisable. A composition caller that drives view_proj and the light slots from the host doesn’t want those stock values layered on top. no_defaults leaves the pass graph exactly as built.

For finer control, reach for the per-kind switches no_default_camera and no_default_light. To keep injection on but swap the stock values for your own, use set_default_camera and set_default_light.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
scene.add(&Model::new(mesh, Material::pbr()))?;
// The host overrides every uniform, so suppress FC's stock camera + light.
scene.no_defaults();
for p in scene.list_passes() {
p.load_previous();
}
2 collapsed lines
Ok(())
}

Turn off default-Camera injection while leaving the default Light in place. By default a Scene with Models but no Camera injects a stock perspective camera at first render. Call this when the host supplies the view projection and the stock camera would only fight for the same uniform.

To turn off both kinds at once, use no_defaults. To keep injection on but supply your own camera, use set_default_camera.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
scene.add(&Model::new(mesh, Material::pbr()))?;
scene.no_default_camera();
2 collapsed lines
Ok(())
}

Turn off default-Light injection while leaving the default Camera in place. By default a Scene with Models but no Light injects a white directional light at first render so the geometry reads as lit. Call this when the host owns the light rig and the stock light would only compete for the same lights.lights[..] slots.

To turn off both kinds at once, use no_defaults. To keep injection on but supply your own light, use set_default_light.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
scene.add(&Model::new(mesh, Material::pbr()))?;
scene.no_default_light();
2 collapsed lines
Ok(())
}

Supply your own default Camera for the Scene to inject at first render in place of FC’s stock perspective camera. This fires only when no Camera arrived through Scene::add; an explicit scene.add(&camera) still wins.

Naming a default re-arms injection. If no_default_camera ran earlier, calling set_default_camera turns camera injection back on with the camera you pass.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Camera, Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
scene.add(&Model::new(mesh, Material::pbr()))?;
let camera = Camera::perspective(1.047, 16.0 / 9.0, 0.1, 100.0)
.look_at([0.0, 1.5, 4.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
scene.set_default_camera(&camera);
2 collapsed lines
Ok(())
}

Supply your own default Light for the Scene to inject at first render in place of FC’s stock white directional light. This fires only when no Light arrived through Scene::add; an explicit scene.add(&light) still wins.

Naming a default re-arms injection. If no_default_light ran earlier, calling set_default_light turns light injection back on with the light you pass.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Light, Material, Mesh, Model, Scene, Vertex};
let mesh = Mesh::new();
mesh.add_vertex(
Vertex::pbr([0.0, 0.5, 0.0]).set(Vertex::UV0, [0.5, 1.0]),
);
let scene = Scene::new();
scene.add(&Model::new(mesh, Material::pbr()))?;
let key = Light::directional([0.3, -1.0, -0.4], [1.0, 0.95, 0.9]);
scene.set_default_light(&key);
2 collapsed lines
Ok(())
}