954 lines
43 KiB
Rust
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());
|
|
}
|
|
}
|