Files
wsg/lib/src/scene/scene.rs
T
Jérôme Bousquié c2cbd7fadb Étape 14: add shadow mapping (directional light, Phase 4.2)
Implement shadow mapping for directional lights:
- Scene::set_shadow_caster(Option<usize>) selects the shadow-casting light
  by packed frame-array index (None disables; point lights rejected at render).
- Lights::get(index) resolves a packed index across the directional/point/spot lists.
- Renderer allocates a shadow depth map, comparison sampler, group-3 bind groups,
  shadow uniform buffer and shadow pipeline; render_scene does a depth-only
  shadow pass before the main pass; compute_shadow_light_view_proj builds an
  orthographic light-space frustum from the scene radius.
- standard_shader: shadow_light_index/light_view_proj/shadow_params uniforms,
  @group(3) depth map + comparison sampler, 3x3 PCF compute_shadow().
- shadow_shader: path/vertex shader with attribute layout matching the shared
  vertex buffer (only position consumed).
- shadow_test example: directional shadow caster casts a PCF-softened shadow
  onto a ground slab; documented in examples README.
2026-09-19 09:48:17 +02:00

495 lines
24 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)
//! - **La Recette**: Scene is central to the "App" facade workflow. In the Phase de Déclaration, users call add_mesh(), add_material(), and add_entity()
//! to build the resource depot. During Phase d'Exécution, 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.
//!
//! ## Étape 7 — Pipeline context owned by the Scene (DRAFT Étape 7.1)
//! Since Étape 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::math::{Geometry, Transform};
use crate::pipeline::PipelineCache;
use crate::resources::{Camera, Lights, Material, Mesh, Texture};
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>,
}
/// 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 (Étape 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 (Étape 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, Étape 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 Étape 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-régression). Read each frame by `Renderer::render_scene`
/// to compute the light `view_proj` and enable shadow sampling.
shadow_caster: Option<usize>,
}
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,
}
}
/// 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 (Étape 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). Étape 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` (Étape 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 Étape 8 the mesh is declared from a CPU `Geometry` (DRAFT Étape 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));
Ok(id.to_string())
}
/// 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 Étape 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
}
/// 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,
/// Étape 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::resources::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
));
}
self.lights
.directional
.push(crate::resources::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::resources::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
));
}
self.lights
.point
.push(crate::resources::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::resources::MAX_LIGHTS {
return Err(format!(
"Cannot add another light: MAX_LIGHTS ({}) reached.",
crate::resources::MAX_LIGHTS
));
}
self.lights.spot.push(crate::resources::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-régression, Étape 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);
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())
}
/// 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 Étape 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));
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 Étape 7 it is resolved from the Mesh
/// (`mesh.material()`) or the Scene's default at draw time (DRAFT Étape 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()
}
}