Files
wsg/lib/src/scene/scene.rs
T
Jérôme Bousquié 35aeb769a8 refactor examples
2026-09-25 10:19:24 +02:00

954 lines
43 KiB
Rust

//! # Scene Module — Resource Depot and Entity Graph (per ARCHI_APP.md)
//!
//! Defines `Scene`, the declarative layer of the WSG architecture. Users register resources (Meshes, Materials) by identifier before
//! the render loop starts, then associate entities via labels. At runtime, Scene provides immutable access to these resources without exposing raw wgpu handles.
//!
//! ## Architecture Notes (per ARCHI_APP.md)
//! - **The Recipe**: Scene is central to the "App" facade workflow. In the Declaration Phase, users call add_mesh(), add_material(), and add_entity()
//! to build the resource depot. During the Execution Phase, Renderer iterates Scene entities for rendering.
//! - **Identifiants**: All resource registration uses string identifiers (`Handle<T>`/String pattern), guaranteeing memory safety
//! and avoiding borrow checker issues during dynamic updates.
//! - **Ergonomie**: Users interact only with entity-level operations (add/remove/get) rather than wgpu buffers/pipelines directly.
//!
//! ## Step 7 — Pipeline context owned by the Scene (DRAFT Step 7.1)
//! Since Step 7 the Scene owns the GPU-facing material pipeline context (`SceneGpu`: device + format + `PipelineCache`)
//! instead of `App`. It can therefore build materials and meshes itself (`add_material_shader`, `create_mesh`) and inject
//! a default material for meshes that carry none (`default_material`).
use crate::core::{Geometry, Transform};
use crate::pipeline::PipelineCache;
use crate::camera::Camera; use crate::lights::Lights; use crate::resources::{BBoxSlot, Material, Mesh, Texture, TransformSlot};
use crate::scene::Entity;
use glam::Vec3;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
/// GPU-facing context the Scene needs to build materials and meshes by itself. Held in
/// `Scene.gpu` and populated once by `Scene::init_gpu` after the `Context`/`Renderer` exist
/// (during `AppRunner::resumed`, before `AppHandler::setup`). The `cache` is interior-mutable
/// (`RefCell`) so a default material can be built lazily from an immutable `&Scene` at render time.
struct SceneGpu {
/// Shared GPU device used to create mesh buffers and compile pipelines.
device: Arc<wgpu::Device>,
/// Surface texture output format, required to build fragment pipelines.
format: wgpu::TextureFormat,
/// Shader compilation cache: compiles/caches RenderPipelines keyed by shader_id + format.
cache: RefCell<PipelineCache>,
}
/// A stable, append-only slot for an entity in the GPU-driven slot buffers (Phase 3, Step 15).
/// Slots are **never freed**: removing an entity leaves a tombstone (its label drops out of the
/// `entities` map) so that slot indices stay stable across frames and the fixed-capacity GPU buffers
/// can be indexed by a constant slot index. The transform itself is read from the `entities` map
/// (by `label`) at pack time; the slot only carries the mesh identity + draw metadata, which are
/// stable once the entity is (re-)added.
#[derive(Clone)]
struct EntitySlot {
/// Entity label (key into the `entities` map; the transform is read from there each frame).
label: String,
/// Stable index of the entity's mesh (position in `mesh_order`) — indexes the GPU bbox buffer.
mesh_index: u32,
/// Draw count for the entity's mesh (vertex count, or index count when indexed) → packed into
/// the transform slot's `flags.z`.
draw_count: u32,
/// Whether the entity's mesh is indexed → packed into the transform slot's `flags.w`.
has_index: bool,
}
/// Per-slot draw descriptor for the GPU-driven render loop (Phase 3). Carries everything the
/// renderer needs to issue one indirect draw: the slot index (→ indirect-args + matrix buffer
/// offset), whether the slot is active (tombstones are skipped on the CPU), the mesh, and whether
/// it is indexed. The world matrix is **not** carried here — it is derived on the GPU (Step 15.5)
/// and read from the matrix buffer by the render pipeline.
#[derive(Clone)]
pub struct SlotDraw {
/// Stable slot index (offset into the indirect-args and matrix buffers, in slot units).
pub slot_index: usize,
/// Whether the slot is active (false = tombstone; the CPU skips it, the GPU zeros its draw args).
pub active: bool,
/// The entity's mesh (vertex/index buffers + material).
pub mesh: Arc<Mesh>,
/// Whether the mesh is indexed (`draw_indexed_indirect` vs `draw_indirect`).
pub has_index: bool,
}
/// Resource depot and entity graph. Stores Meshes and Materials keyed by identifier strings,
/// maps entity labels to their associated `Entity` (mesh + transform) for rendering iteration,
/// and holds the scene's active `Camera` used to build the per-frame view/projection matrices (Step 4.3).
/// Created once during application setup; entities are added before the render loop starts.
pub struct Scene {
/// Map of mesh identifiers to owned `Arc<Mesh>` instances. Populated via `add_mesh()`.
meshes: HashMap<String, Arc<Mesh>>,
/// Map of material identifiers to owned `Arc<Material>` instances. Populated via `add_material()`.
materials: HashMap<String, Arc<Material>>,
/// Map of diffuse texture identifiers to owned `Arc<Texture>` instances (Step 10, D4).
/// Populated via `add_texture()`; materials reference them via `add_material_texture()` by id.
textures: HashMap<String, Arc<Texture>>,
/// Map of entity labels to `Entity` associations. Populated via `add_entity()` / `add_entity_with_transform()`.
entities: HashMap<String, Entity>,
/// Active camera used for rendering. Read each frame by `Renderer::render_scene` to compute the
/// view/projection matrices written into the frame uniform buffer. Replaced via `set_camera()`.
camera: Camera,
/// Owned pipeline context (device + format + cache), `None` until `init_gpu` is called.
gpu: Option<SceneGpu>,
/// Lazily-built default `standard` material, cached so `default_material` costs O(1) after the
/// first call. Interior-mutable so it can be filled from an immutable `&Scene` (used by the Renderer).
default_material: RefCell<Option<Arc<Material>>>,
/// Global light list (directional + point), uploaded into the frame uniforms each frame
/// (Phase 4.2, Step 12). Default = one white directional light along +Z (non-regression).
lights: Lights,
/// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white.
ambient: [f32; 3],
/// Optional shadow-casting light index (DRAFT Step 14, D1): the index (in the packed frame
/// array: directionals, then points, then spots) of the single light that casts a shadow.
/// `None` = shadows off (default, non-regression). Read each frame by `Renderer::render_scene`
/// to compute the light `view_proj` and enable shadow sampling.
shadow_caster: Option<usize>,
/// Stable, append-only entity slots for the GPU-driven buffers (Phase 3). Grows only; removed
/// entities leave tombstones so slot indices stay stable.
entity_slots: Vec<EntitySlot>,
/// Map of entity label to slot index (O(1) lookup so a re-added label reuses its slot).
slot_of_label: HashMap<String, usize>,
/// Ordered mesh identifiers (index = the stable `mesh_index` used by slots and the bbox buffer).
mesh_order: Vec<String>,
}
impl Scene {
/// Creates an empty scene with no registered resources or entities and a default camera
/// (`Camera::default()`: position (0,0,3), looking at origin, 45° perspective).
/// Called at application startup before any resource registration. The GPU pipeline context is
/// empty (`gpu: None`) until `init_gpu` is called once the `Context`/`Renderer` exist.
pub fn new() -> Self {
Self {
meshes: HashMap::new(),
materials: HashMap::new(),
textures: HashMap::new(),
entities: HashMap::new(),
camera: Camera::default(),
gpu: None,
default_material: RefCell::new(None),
lights: Lights::new(),
ambient: [1.0, 1.0, 1.0],
shadow_caster: None,
entity_slots: Vec::new(),
slot_of_label: HashMap::new(),
mesh_order: Vec::new(),
}
}
/// Attaches the GPU-facing pipeline context (device + queue + format + `PipelineCache`) to this
/// Scene, enabling it to build materials and meshes itself. Called once during `AppRunner::resumed`,
/// just after the `Context`/`Renderer` are created and **before** `AppHandler::setup`, so setup
/// can register shaders/materials/textures/meshes/entities using `self`. Returns `&mut self` for chaining.
/// Inputs: device — shared GPU device (Arc clone); queue — GPU command queue (used to build the
/// geometry); format — surface texture output format.
pub fn init_gpu(
&mut self,
device: Arc<wgpu::Device>,
queue: wgpu::Queue,
format: wgpu::TextureFormat,
) -> &mut Self {
let cache = PipelineCache::new(device.clone(), queue.clone());
self.gpu = Some(SceneGpu {
device,
format,
cache: RefCell::new(cache),
});
self
}
/// Returns the owned GPU pipeline context, panicking with a clear message if it has not been
/// initialized yet. Called internally whenever the Scene builds materials/meshes.
fn gpu(&self) -> &SceneGpu {
self.gpu
.as_ref()
.expect("scene pipeline not initialized yet — call Scene::init_gpu once after the GPU context is ready")
}
/// Returns the Scene's shared GPU device (used to create mesh buffers and compile pipelines).
/// Panics if the Scene's pipeline context has not been initialized (i.e. outside `resumed`).
pub fn device(&self) -> &wgpu::Device {
self.gpu().device.as_ref()
}
/// Returns the surface texture output format used to build fragment pipelines.
/// Panics if the Scene's pipeline context has not been initialized.
pub fn format(&self) -> wgpu::TextureFormat {
self.gpu().format
}
/// Registers an external WGSL shader file path under a shader id in the Scene's pipeline cache.
/// Sugar for `cache().register_shader(id, path)` so callers never touch the cache directly.
/// Returns Ok(id) or Err(String) if the id is already registered.
pub fn register_shader(&mut self, id: &str, path: &str) -> Result<String, String> {
self.gpu().cache.borrow_mut().register_shader(id, path)
}
/// Builds and registers a Material from a shader id, using the Scene's pipeline context
/// (format + cache). This is the declarative way to declare an appearance without touching
/// `Material::new` or the `PipelineCache` directly. Returns Ok(id) or Err(String) if the id exists.
pub fn add_material_shader(&mut self, id: &str, shader_id: &str) -> Result<String, String> {
if self.materials.contains_key(id) {
return Err(format!("Material ID '{}' already exists.", id));
}
let mut cache = self.gpu().cache.borrow_mut();
let material = Arc::new(Material::new(self.gpu().format, shader_id, &mut cache));
drop(cache);
self.materials.insert(id.to_string(), material);
Ok(id.to_string())
}
/// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so
/// materials can reference it declaratively (Step 10, D4). The texture is wrapped in `Arc` for
/// zero-copy sharing across materials. Returns Ok(id) or Err(String) if the id already exists.
/// Inputs: id (unique identifier), texture (GPU diffuse texture to register).
pub fn add_texture(&mut self, id: &str, texture: Texture) -> Result<String, String> {
if self.textures.contains_key(id) {
return Err(format!("Texture ID '{}' already exists.", id));
}
self.textures.insert(id.to_string(), Arc::new(texture));
Ok(id.to_string())
}
/// Retrieves a registered diffuse texture by its identifier, if present. Called by the user to
/// read back a texture (or by internals when resolving material↔texture links). Step 10 (D4).
pub fn get_texture(&self, id: &str) -> Option<&Arc<Texture>> {
self.textures.get(id)
}
/// Builds and registers a Material from a shader id **and** a diffuse texture registered via
/// [`Scene::add_texture`]. The material samples `texture_id` (Step 10, D4). Returns Ok(id) or
/// Err(String) if the material id exists or the texture id does not. Inputs: id (material id to
/// register), shader_id (pipeline key), texture_id (existing texture id in this Scene).
pub fn add_material_texture(
&mut self,
id: &str,
shader_id: &str,
texture_id: &str,
) -> Result<String, String> {
if self.materials.contains_key(id) {
return Err(format!("Material ID '{}' already exists.", id));
}
let texture = self
.textures
.get(texture_id)
.ok_or_else(|| format!("Texture '{}' does not exist.", texture_id))?
.clone();
let mut cache = self.gpu().cache.borrow_mut();
let material = Arc::new(Material::new_with_texture(
self.gpu().format,
shader_id,
texture,
&mut cache,
));
drop(cache);
self.materials.insert(id.to_string(), material);
Ok(id.to_string())
}
/// Builds, (optionally) links to a Material, and registers a Mesh in one declarative call.
/// Since Step 8 the mesh is declared from a CPU `Geometry` (DRAFT Step 8, D4) instead of raw
/// `&[Vertex]`. This builds the shared `Arc<Geometry>` and creates the GPU buffers via
/// `Mesh::from_geometry(device, arc, ...)`, then — if `material` is `Some(name)` — resolves that
/// material id and attaches it to the mesh (`Mesh::set_material`). When `material` is `None`, the
/// mesh carries no material and the Scene's `default_material` is used at draw time.
/// Returns Ok(id) or Err(String) if the id exists or the named material does not.
pub fn create_mesh(
&mut self,
id: &str,
geometry: Geometry,
material: Option<&str>,
) -> Result<String, String> {
if self.meshes.contains_key(id) {
return Err(format!("Mesh ID '{}' already exists.", id));
}
let mut mesh = Mesh::from_geometry(self.device(), Arc::new(geometry), None);
if let Some(name) = material {
let mat = self
.materials
.get(name)
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
.clone();
mesh.set_material(mat);
}
self.meshes.insert(id.to_string(), Arc::new(mesh));
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
/// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one
/// declarative call (Step 19, D6/D7). Level 0 is `geometry` (byte-exact); levels 1.. are
/// auto-generated by quadric edge collapse (`Geometry::generate_lod_levels`, D10) at halving
/// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the
/// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass.
///
/// Inputs: id (unique mesh id), geometry (level 0 — the full mesh), material (optional
/// material id, same rule as [`create_mesh`]), levels (2..=MAX_LOD_LEVELS).
/// Returns `Err` if the id exists, the material is unknown, `levels` is out of range, or the
/// packed vertex total exceeds the u16 index limit (65535).
pub fn create_mesh_with_lod(
&mut self,
id: &str,
geometry: Geometry,
material: Option<&str>,
levels: u8,
) -> Result<String, String> {
use crate::utils::conf::MAX_LOD_LEVELS;
if self.meshes.contains_key(id) {
return Err(format!("Mesh ID '{}' already exists.", id));
}
if levels < 2 || u32::from(levels) > MAX_LOD_LEVELS {
return Err(format!(
"LOD levels must be in 2..={MAX_LOD_LEVELS} (got {levels})."
));
}
let lod_levels: Vec<Arc<Geometry>> = geometry
.generate_lod_levels(levels)
.into_iter()
.map(Arc::new)
.collect();
let total: u32 = lod_levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(format!(
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit; use fewer levels or a smaller mesh."
));
}
let mut mesh = Mesh::from_geometry_lod(
self.device(),
lod_levels,
None,
crate::resources::LodMode::Auto,
);
if let Some(name) = material {
let mat = self
.materials
.get(name)
.ok_or_else(|| format!("Material '{}' does not exist.", name))?
.clone();
mesh.set_material(mat);
}
self.meshes.insert(id.to_string(), Arc::new(mesh));
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
/// Adds (or replaces) an **explicitly provided** LOD level on an existing mesh (Step 19, D6).
/// The level is packed into the mesh's buffers alongside the others (D7) and the per-mesh LOD
/// table is updated (it is re-uploaded every frame, so the change takes effect next frame).
///
/// Inputs: id (existing mesh id), level (index ≥ 1; must be ≤ the current level count —
/// append at the end or replace in place), geometry (the level's geometry).
/// Validation: the level must validate, carry the **same attribute set and indexedness** as
/// level 0, keep the packed vertex total under 65536, and stay within `MAX_LOD_LEVELS`.
pub fn add_mesh_lod(&mut self, id: &str, level: u8, geometry: Geometry) -> Result<(), String> {
use crate::utils::conf::MAX_LOD_LEVELS;
let current = self
.meshes
.get(id)
.ok_or_else(|| format!("Mesh '{}' does not exist.", id))?;
let levels_count = current.num_lod_levels();
if level < 1 || u32::from(level) > MAX_LOD_LEVELS {
return Err(format!(
"LOD level must be in 1..={MAX_LOD_LEVELS} (got {level})."
));
}
if level as usize > levels_count {
return Err(format!(
"Mesh '{}' has {levels_count} level(s); level {level} does not exist and the next free level is {levels_count}.",
id
));
}
let l0 = current.geometry();
let attr = |g: &Geometry| (g.normals.is_some(), g.uvs.is_some(), g.colors.is_some());
if attr(&geometry) != attr(l0) {
return Err(format!(
"LOD level {level} of mesh '{}' must have the same attribute set (normals/UVs/colors) as level 0.",
id
));
}
if geometry.indices().is_some() != l0.indices().is_some() {
return Err(format!(
"LOD level {level} of mesh '{}' must have the same indexedness as level 0.",
id
));
}
geometry
.validate()
.map_err(|e| format!("LOD level {level} of mesh '{}': {e}", id))?;
let mut new_levels: Vec<Arc<Geometry>> = (0..levels_count)
.map(|i| current.lod_levels_arc(i))
.collect();
if level as usize == levels_count {
if levels_count >= MAX_LOD_LEVELS as usize {
return Err(format!(
"Mesh '{}' already has the maximum of {MAX_LOD_LEVELS} LOD levels.",
id
));
}
new_levels.push(Arc::new(geometry)); // append the next level (L_k at vec index k)
} else {
new_levels[level as usize] = Arc::new(geometry); // replace L_k in place (vec index k)
}
let total: u32 = new_levels.iter().map(|l| l.positions.len() as u32).sum();
if total >= 65536 {
return Err(format!(
"Packed LOD vertex total ({total}) exceeds the 65535 u16 index limit."
));
}
let material = current.material().cloned();
let mesh = Mesh::from_geometry_lod(
self.device(),
new_levels,
material,
crate::resources::LodMode::Explicit,
);
// Entities reference the mesh by (stable) index, not by Arc — swapping the Arc is safe.
self.meshes.insert(id.to_string(), Arc::new(mesh));
Ok(())
}
/// The per-mesh LOD tables in `mesh_order` order (one 80-byte table per mesh, level 0 first)
/// — the payload of the GPU `lod_tables` buffer, uploaded every frame (Step 19, D7).
pub fn mesh_lod_tables(&self) -> Vec<crate::resources::LodTable> {
self.mesh_order
.iter()
.map(|name| self.meshes[name].lod_table())
.collect()
}
/// Returns the Scene's default material: the `standard` shader pipeline, built lazily on first
/// call and cached afterwards. Used by `Renderer::render_scene` for meshes that carry no material.
/// Note: the flat (unlit) look is *not* a property of this material — it is driven by the
/// orthogonal `Renderer::set_unlit` flag (DRAFT Step 7.3.5).
pub fn default_material(&self) -> Arc<Material> {
if let Some(m) = self.default_material.borrow().as_ref() {
return m.clone();
}
let material = Arc::new(Material::new(
self.gpu().format,
"standard",
&mut self.gpu().cache.borrow_mut(),
));
*self.default_material.borrow_mut() = Some(material.clone());
material
}
/// Replaces the scene's active camera. The new camera is used from the next frame onward by
/// `Renderer::render_scene` to build the view/projection matrices and the camera position.
/// Inputs: camera — the new camera configuration. Call during setup or `AppHandler::update`
/// to move/re-orient the view (e.g. orbit or FPS controls).
pub fn set_camera(&mut self, camera: Camera) {
self.camera = camera;
}
/// Returns a reference to the scene's active camera.
/// Called by users to read the current camera (e.g. to move it based on input) and internally by
/// `Renderer::render_scene` to upload its matrices.
pub fn camera(&self) -> &Camera {
&self.camera
}
/// Returns a mutable reference to the scene's active camera, for in-place per-frame edits
/// (e.g. [`CameraController::apply_to`](crate::camera::CameraController) during `update`).
pub fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
/// Adds a directional light (direction **from the surface toward the light**, color, intensity).
/// Lights are global to the scene and uploaded into the frame uniforms each frame (Phase 4.2,
/// Step 12). Returns `Err` if the scene would exceed `MAX_LIGHTS` (capacity is bounded; no
/// dynamic UBO allocation). Inputs: dir (direction toward the light source), color (rgb),
/// intensity (multiplier).
pub fn add_directional_light(
&mut self,
dir: Vec3,
color: [f32; 3],
intensity: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::lights::MAX_LIGHTS
));
}
self.lights
.directional
.push(crate::lights::directional_light(
dir, color, intensity,
));
Ok(())
}
/// Adds a point light (world position, color, intensity, attenuation radius).
/// Returns `Err` if the scene would exceed `MAX_LIGHTS`. Inputs: pos (world position of the
/// light), color (rgb), intensity (multiplier), radius (linear falloff to zero at this distance).
pub fn add_point_light(
&mut self,
pos: Vec3,
color: [f32; 3],
intensity: f32,
radius: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::lights::MAX_LIGHTS
));
}
self.lights
.point
.push(crate::lights::point_light(
pos, color, intensity, radius,
));
Ok(())
}
/// Adds a spot light (world position, cone axis from the light toward the scene, color,
/// intensity, attenuation radius, half-angle in radians). Returns `Err` if the scene would
/// exceed `MAX_LIGHTS`. Inputs: pos (world position of the light), dir (cone axis, from the
/// light toward the scene), color (rgb), intensity (multiplier), radius (linear falloff to
/// zero at this distance), half_angle (cone half-angle in radians).
pub fn add_spot_light(
&mut self,
pos: Vec3,
dir: Vec3,
color: [f32; 3],
intensity: f32,
radius: f32,
half_angle: f32,
) -> Result<(), String> {
if self.lights.len() >= crate::lights::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::lights::MAX_LIGHTS
));
}
self.lights.spot.push(crate::lights::spot_light(
pos, dir, color, intensity, radius, half_angle,
));
Ok(())
}
/// Replaces the scene's global light list. The `Scene` keeps ownership; the list is uploaded
/// into the frame uniforms each frame. Used to reset or bulk-configure lighting.
pub fn set_lights(&mut self, lights: Lights) {
self.lights = lights;
}
/// Returns a reference to the scene's global light list (directional + point).
/// Read by `Renderer::render_scene` each frame to upload the light array.
pub fn lights(&self) -> &Lights {
&self.lights
}
/// Selects the single shadow-casting light by **its index in the packed frame array**
/// (directionals first, then point lights, then spots — same order as
/// `Lights::into_frame_array`). `None` disables shadows (default, non-regression, Step 14 D7).
/// The light must be **directional or spot**; a point light index disables the shadow pass
/// (cubemap shadows are out of scope, D6). Inputs: index — the light's packed-array index, or
/// `None` to turn shadows off.
pub fn set_shadow_caster(&mut self, index: Option<usize>) {
self.shadow_caster = index;
}
/// Returns the index of the scene's shadow-casting light (`None` = shadows off).
/// Read by `Renderer::render_scene` each frame to decide whether to run the shadow pass.
pub fn shadow_caster(&self) -> Option<usize> {
self.shadow_caster
}
/// Removes all lights (directional, point and spot). The fragment shader then contributes
/// only the ambient term. Useful for flat look without toggling `unlit`.
pub fn clear_lights(&mut self) {
self.lights = Lights {
directional: Vec::new(),
point: Vec::new(),
spot: Vec::new(),
};
}
/// Sets the ambient hemisphere color (rgb). Default is white.
pub fn set_ambient(&mut self, color: [f32; 3]) {
self.ambient = color;
}
/// Returns the scene's ambient hemisphere color (rgb).
pub fn ambient(&self) -> [f32; 3] {
self.ambient
}
/// Registers a Mesh in the scene under a unique identifier.
/// Inputs: id (unique key), mesh (Arc-wrapped Mesh instance). Returns Ok(id) on success or Err(String) if already exists.
/// Called during scene initialization when building the resource depot.
pub fn add_mesh(&mut self, id: &str, mesh: Arc<Mesh>) -> Result<String, String> {
if self.meshes.contains_key(id) {
return Err(format!("Mesh ID '{}' already exists.", id));
}
self.meshes.insert(id.to_string(), mesh);
self.mesh_order.push(id.to_string());
Ok(id.to_string())
}
/// Registers a Material in the scene under a unique identifier.
/// Inputs: id (unique key), material (Arc-wrapped Material instance). Returns Ok(id) on success or Err(String) if already exists.
/// Called during scene initialization when building the resource depot.
pub fn add_material(&mut self, id: &str, material: Arc<Material>) -> Result<String, String> {
if self.materials.contains_key(id) {
return Err(format!("Material ID '{}' already exists.", id));
}
self.materials.insert(id.to_string(), material);
Ok(id.to_string())
}
/// Sets the emissive color on a registered material (Étape 22, 6.2).
/// Uses `Arc::get_mut` — only works if the material has a single reference (i.e., no mesh
/// has captured it yet). Call BEFORE `create_mesh` to pre-set the emissive.
/// Returns Err if the material doesn't exist or has multiple references.
pub fn set_material_emissive(&mut self, id: &str, emissive: [f32; 4]) -> Result<(), String> {
let mat = self
.materials
.get_mut(id)
.ok_or_else(|| format!("Material '{}' not found.", id))?;
let inner = Arc::get_mut(mat)
.ok_or_else(|| format!("Material '{}' has multiple references; cannot modify in place.", id))?;
inner.emissive = emissive;
Ok(())
}
/// Associates an entity label with a mesh for rendering iteration, using an identity transform.
/// The appearance (Material) is read from the Mesh itself (or the Scene's default), so no
/// material_id is needed here (DRAFT Step 7.3).
/// Inputs: label (entity identifier string), mesh_id (key into meshes map).
/// Returns Ok(label) on success or Err(String) if the referenced mesh does not exist.
/// Called during scene initialization to build the renderable entity graph.
/// Internal steps: 1) validate mesh_id exists → 2) insert an `Entity` with identity transform.
pub fn add_entity(&mut self, label: &str, mesh_id: &str) -> Result<String, String> {
self.add_entity_with_transform(label, mesh_id, Transform::identity())
}
/// Associates an entity label with a mesh together with an explicit world-space transform.
/// Inputs: label (entity identifier string), mesh_id (key into meshes map),
/// transform (world-space placement). Returns Ok(label) on success or Err(String) if the
/// referenced mesh does not exist. Called during scene initialization to build the renderable entity graph.
/// Internal steps: 1) validate mesh_id exists → 2) insert the `Entity` into the entities HashMap.
pub fn add_entity_with_transform(
&mut self,
label: &str,
mesh_id: &str,
transform: Transform,
) -> Result<String, String> {
if !self.meshes.contains_key(mesh_id) {
return Err(format!("Mesh '{}' does not exist.", mesh_id));
}
self.entities
.insert(label.to_string(), Entity::new(mesh_id, transform));
// Keep the stable slot in sync (Phase 3): a re-added label reuses its slot (stable index);
// a new label appends a slot. The mesh index + draw metadata are read from the mesh.
let mesh_index = self
.mesh_order
.iter()
.position(|id| id == mesh_id)
.expect("mesh validated above") as u32;
let mesh = &self.meshes[mesh_id];
let has_index = mesh.index_buffer.is_some();
let draw_count = if has_index {
mesh.num_indices
} else {
mesh.num_vertices
};
let slot_index = match self.slot_of_label.get(label) {
Some(&i) => i,
None => {
let i = self.entity_slots.len();
self.slot_of_label.insert(label.to_string(), i);
self.entity_slots.push(EntitySlot {
label: label.to_string(),
mesh_index: 0,
draw_count: 0,
has_index: false,
});
i
}
};
self.entity_slots[slot_index] = EntitySlot {
label: label.to_string(),
mesh_index,
draw_count,
has_index,
};
Ok(label.to_string())
}
/// Retrieves a Mesh by its registered identifier.
/// Called by Renderer during frame rendering to obtain vertex data for draw calls.
pub fn get_mesh(&self, id: &str) -> Option<&Arc<Mesh>> {
self.meshes.get(id)
}
/// Retrieves a Material by its registered identifier.
/// Called by Renderer during frame rendering to obtain pipeline reference for draw calls.
pub fn get_material(&self, id: &str) -> Option<&Arc<Material>> {
self.materials.get(id)
}
/// Iterates all entity associations, yielding (label, mesh_ref, transform_ref) tuples.
/// The Material is **not** yielded here: since Step 7 it is resolved from the Mesh
/// (`mesh.material()`) or the Scene's default at draw time (DRAFT Step 7.3.4).
/// Called by the orchestrator during each render pass to draw every entity in order.
pub fn iter_entities(&self) -> impl Iterator<Item = (&str, &Arc<Mesh>, &Transform)> + '_ {
self.entities.iter().map(|(label, entity)| {
let mesh = self.meshes.get(entity.mesh_id()).unwrap(); // safe: add_entity validates existence
(label.as_str(), mesh, entity.transform())
})
}
/// Returns a reference to the transform of the entity with the given label, if it exists.
/// Called by the user to read an entity's current placement during updates.
pub fn entity_transform(&self, label: &str) -> Option<&Transform> {
self.entities.get(label).map(|e| e.transform())
}
/// Overwrites the transform of the entity with the given label.
/// Returns true if the entity existed and was updated, false otherwise.
/// Called by the user to move/rotate/scale an entity during `AppHandler::update`.
pub fn set_entity_transform(&mut self, label: &str, transform: Transform) -> bool {
match self.entities.get_mut(label) {
Some(entity) => {
entity.set_transform(transform);
true
}
None => false,
}
}
/// Removes an entity from the graph without freeing its underlying resources.
/// The referenced Mesh and Material remain registered; only the association is dropped.
/// Called during dynamic updates when an entity should be hidden or removed temporarily.
pub fn remove_entity(&mut self, label: &str) -> bool {
self.entities.remove(label).is_some()
}
/// Returns the number of registered entities in this scene.
/// Called for diagnostic logging or culling decisions (e.g., skip rendering empty scenes).
pub fn entity_count(&self) -> usize {
self.entities.len()
}
// ========================================================================
// Phase 3 — GPU-driven entity slot accessors (Step 15)
// ========================================================================
// These expose the stable slot system to the Renderer: the packed transform slots (uploaded
// to the GPU each frame), the per-slot draw descriptors (for the indirect render loop), the
// per-mesh bounding boxes (uploaded once), and the slot/mesh counts. The world matrices are
// derived on the GPU; these methods only feed the CPU→GPU inputs and the draw-loop metadata.
/// Packs the stable entity slots into GPU [`TransformSlot`]s (one per slot; tombstones →
/// inactive). The renderer uploads this to the transform buffer each frame (Phase 3, Step 15).
pub fn packed_transform_slots(&self) -> Vec<TransformSlot> {
self.entity_slots
.iter()
.map(|slot| match self.entities.get(&slot.label) {
Some(entity) => TransformSlot::from_transform(
entity.transform(),
slot.mesh_index,
slot.draw_count,
slot.has_index,
),
None => TransformSlot::inactive(),
})
.collect()
}
/// Iterates the stable entity slots as per-slot draw descriptors (Phase 3). Each item carries
/// the slot index (→ indirect-args/matrix buffer offset), whether the slot is active (tombstones
/// are skipped on the CPU), the mesh, and whether it is indexed. Used by the indirect render loop.
pub fn iter_slot_draws(&self) -> impl Iterator<Item = SlotDraw> + '_ {
self.entity_slots
.iter()
.enumerate()
.map(|(i, slot)| SlotDraw {
slot_index: i,
active: self.entities.contains_key(&slot.label),
mesh: self.meshes[&self.mesh_order[slot.mesh_index as usize]].clone(),
has_index: slot.has_index,
})
}
/// The local-space bounding boxes for all registered meshes, in `mesh_index` order (one per
/// mesh). Uploaded once to the GPU bbox buffer (Phase 3). Meshes without a bounding box get a
/// degenerate (all-zero) box, which the cull pass treats as a zero-radius sphere.
pub fn mesh_bboxes(&self) -> Vec<BBoxSlot> {
self.mesh_order
.iter()
.map(|id| {
self.meshes[id]
.geometry()
.bbox()
.map(|b| BBoxSlot::from_bbox(&b))
.unwrap_or_else(BBoxSlot::empty)
})
.collect()
}
/// Stable index of `mesh_id` in the ordered mesh list (its slot in the GPU bbox buffer), if
/// the mesh is registered.
pub fn mesh_index_of(&self, mesh_id: &str) -> Option<u32> {
self.mesh_order
.iter()
.position(|id| id == mesh_id)
.map(|p| p as u32)
}
/// The registered mesh at a stable `mesh_index` (panics if the index is out of range; in
/// practice it is always valid, being derived from `mesh_order` positions).
pub fn mesh_by_index(&self, index: u32) -> &Arc<Mesh> {
&self.meshes[&self.mesh_order[index as usize]]
}
/// Number of entity slots (tombstones included) — the `num_slots` written to the cull uniforms
/// (slots at/beyond this are no-ops on the GPU).
pub fn num_slots(&self) -> usize {
self.entity_slots.len()
}
/// Number of *active* entity slots (tombstones excluded) — the live entity count.
pub fn num_active_slots(&self) -> usize {
self.entity_slots
.iter()
.filter(|s| self.entities.contains_key(&s.label))
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_scene_is_empty() {
let scene = Scene::new();
assert_eq!(scene.entity_count(), 0);
assert_eq!(scene.iter_entities().count(), 0);
assert!(scene.get_mesh("nope").is_none());
assert!(scene.get_material("nope").is_none());
assert_eq!(scene.shadow_caster(), None);
assert_eq!(scene.ambient(), [1.0, 1.0, 1.0]);
}
#[test]
fn default_camera_looks_at_origin_from_plus_z() {
let scene = Scene::new();
let cam = scene.camera();
assert_eq!(cam.position, Vec3::new(0.0, 0.0, 3.0));
assert_eq!(cam.target, Vec3::ZERO);
}
#[test]
fn camera_set_and_get_roundtrip() {
let mut scene = Scene::new();
let cam = Camera::new(Vec3::new(5.0, 5.0, 5.0), Vec3::ZERO, Vec3::Y)
.with_perspective(1.0, 0.5, 50.0);
scene.set_camera(cam.clone());
assert_eq!(scene.camera().position, Vec3::new(5.0, 5.0, 5.0));
scene.camera_mut().target = Vec3::new(1.0, 0.0, 0.0);
assert_eq!(scene.camera().target, Vec3::new(1.0, 0.0, 0.0));
}
#[test]
fn default_light_list_has_one_directional() {
let scene = Scene::new();
assert_eq!(scene.lights().len(), 1);
assert_eq!(scene.lights().directional.len(), 1);
assert_eq!(scene.lights().point.len(), 0);
assert_eq!(scene.lights().spot.len(), 0);
}
#[test]
fn add_lights_until_capacity_then_rejected() {
let mut scene = Scene::new(); // starts with the default directional (1 light)
scene
.add_point_light(Vec3::ZERO, [1.0, 1.0, 1.0], 1.0, 5.0)
.unwrap();
while scene.lights().len() < crate::lights::MAX_LIGHTS {
scene
.add_directional_light(Vec3::Z, [1.0, 1.0, 1.0], 1.0)
.unwrap();
}
assert_eq!(scene.lights().len(), crate::lights::MAX_LIGHTS);
assert!(
scene
.add_spot_light(Vec3::Z, Vec3::NEG_Z, [1.0, 1.0, 1.0], 1.0, 5.0, 0.5)
.is_err()
);
}
#[test]
fn clear_lights_keeps_ambient() {
let mut scene = Scene::new();
scene.set_ambient([0.5, 0.2, 0.1]);
scene.clear_lights();
assert!(scene.lights().is_empty());
assert_eq!(scene.ambient(), [0.5, 0.2, 0.1]);
}
#[test]
fn shadow_caster_set_and_get() {
let mut scene = Scene::new();
scene.set_shadow_caster(Some(0));
assert_eq!(scene.shadow_caster(), Some(0));
scene.set_shadow_caster(None);
assert_eq!(scene.shadow_caster(), None);
}
#[test]
fn add_entity_rejects_unknown_mesh() {
let mut scene = Scene::new();
assert!(scene.add_entity("e1", "missing_mesh").is_err());
assert!(!scene.set_entity_transform("missing", Transform::identity()));
assert!(!scene.remove_entity("missing"));
}
#[test]
fn gpu_driven_slot_bookkeeping_is_empty_when_no_entities() {
// The GPU-driven slot system starts empty; the full slot/mesh interplay requires a
// wgpu device (to build meshes) and is validated by the examples.
let scene = Scene::new();
assert_eq!(scene.num_slots(), 0);
assert_eq!(scene.num_active_slots(), 0);
assert!(scene.packed_transform_slots().is_empty());
assert!(scene.mesh_bboxes().is_empty());
assert!(scene.iter_slot_draws().next().is_none());
assert!(scene.mesh_index_of("nope").is_none());
}
#[test]
fn packed_slot_roundtrips_transform_and_is_inactive_when_tombstoned() {
// Exercises the CPU→GPU packing (GPU-independent): an active slot packs the transform +
// mesh index + draw count + index flag; a tombstoned slot packs to `inactive()`.
let t = Transform {
translation: glam::Vec3::new(1.0, 2.0, 3.0),
..Transform::identity()
};
let slot = TransformSlot::from_transform(&t, 7, 36, true);
assert_eq!(slot.mesh_index(), 7, "mesh index packed in flags.x");
assert!(slot.is_active(), "active = 1");
assert_eq!(slot.draw_count(), 36, "draw count packed in flags.z");
assert!(slot.has_index(), "indexed flag packed in flags.w");
assert!(
slot.translation
.iter()
.zip([1.0, 2.0, 3.0].iter())
.all(|(a, b)| (a - b).abs() < 1e-5)
);
let inactive = TransformSlot::inactive();
assert!(!inactive.is_active(), "inactive active-flag = 0");
assert_eq!(inactive.mesh_index(), 0);
assert_eq!(inactive.draw_count(), 0);
assert!(!inactive.has_index());
}
}