diff --git a/README.md b/README.md index d74d1ca..b6396a3 100644 --- a/README.md +++ b/README.md @@ -186,3 +186,4 @@ The architecture docs live in `docs/tech/` and are written in **French**. Each d 7. ✅ **CPU geometry storage (Étape 8)** — `Mesh` retains a shared `Arc` (CPU source of truth with colors) alongside its GPU buffers; meshes are declared from a `Geometry` via `Mesh::from_geometry`/`Scene::create_mesh(id, geometry, material)` instead of raw `&[Vertex]` arrays. (Done 2026-09-18; `transform` stays on `Entity` — deviation D3.) 8. ✅ **Diffuse textures (Étape 10, Phase 4.1)** — `resources::Texture` (GPU image: device+view+sampler, `Rgba8UnormSrgb`, loaders `from_rgba8`/`from_bytes`/`from_file`) attached to a `Material` as diffuse texture. The `standard` shader samples it via bind group **@2** (shared layout: sampler+texture); UVs are forwarded as vertex attribute location 2. Without a texture the material uses a shared 1×1 white placeholder so lit and unlit rendering are unchanged (no regression). The `cube` example now uses a procedural checkerboard texture. (Done 2026-09-18.) 9. ✅ **Window resize (Étape 11, Phase 4.4)** — `App::resize` reconfigures the surface (`Context::configure`) and recreates the depth texture (`Renderer::resize_depth`) together on each `WindowEvent::Resized`, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on the `cube` example.) +10. ✅ **Multi-lighting (Étape 12, Phase 4.2)** — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame `FrameUniforms` array each frame. `Scene::add_directional_light` / `add_point_light` / `set_ambient` / `clear_lights` configure it; `FrameUniforms::default()` (one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. The `standard` fragment accumulates ambient + all lights; the `cube` example adds a warm point light on top of the default directional. (Done 2026-09-18.) diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 1e13d5b..5b76e14 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,15 +1,4 @@ # DRAFT — Étape suivante -> 📅 **Document vidé le 2026-09-18** (fin de l'Étape 11, Resize — Phase 4.4, bilan archivé -> dans l'historique git). Ce fichier accueillera le plan de l'étape suivante. -> -> **Étape 11 (2026-09-18) : Gestion du Resize (Surface + Depth) — FAIT & vérifié.** -> `App::resize(w,h)` (`lib/src/app.rs`) reconfigure la surface via `Context::configure` et -> recrée la depth texture via `Renderer::resize_depth` (+ `set_format`), avec re-synchronisation -> de la Scene si le format change (D4). `AppRunner::window_event` branche `WindowEvent::Resized` -> (garde 0×0, D3) et `RedrawRequested` (garde taille nulle, D6). -> Vérifié : `cargo build`/`test` workspace + compilation des exemples OK, et **au runtime** -> (exemple `cube`) le resize (agrandir + rétrécir) ne produit ni crash, ni artefact — rendu -> correct à la nouvelle taille, aspect non déformé. -> -> Source de vérité = code + README.md. Ce document est vidé à la complétion de chaque étape. +> DRAFT vide. L'Étape 12 (Phase 4.2 — multi-lumières) est terminée et son bilan est archivé dans l'historique git +> (commit correspondant). Préparer le plan de l'étape suivante ici. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ed365c..841b512 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -139,7 +139,8 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } ### 4.2 Éclairage avancé - [x] Lumières hémisphériques *(déjà dans le `standard_shader` : mélange hémisphérique, Étape 2)* -- [ ] Support multi-lumières (directionnelles, ponctuelles) +- [x] Support multi-lumières (directionnelles, ponctuelles) *(Étape 12, 2026-09-18 : liste globale dans la `Scene`, tableau `FrameUniforms.lights[8]`, shader accumule ambiant + directionnelles + ponctuelles, `MAX_LIGHTS = 8`)* +- [ ] Lumières spot (cône + angle) *(futur, post-Étape 12 — hors périmètre du multi-lumières initial ; extension triviale : même struct `Light` + champ d'angle/orientation et boucle d'accumulation dédiée)* - [ ] Shadows (optionnel) ### 4.3 Optimisations diff --git a/lib/examples/cube.rs b/lib/examples/cube.rs index aece89f..ce0192a 100644 --- a/lib/examples/cube.rs +++ b/lib/examples/cube.rs @@ -10,7 +10,7 @@ //! la texture est générée *procéduralement* (damier RGBA 8×8) pour rester autonome, sans asset sur disque. //! La caméra active par défaut (`Scene::default`, position (0,0,3), fov 45°) cadre le cube, et //! `AppHandler::update` fait tourner l'entité via `set_entity_transform` chaque frame. -use glam::Quat; +use glam::{Quat, Vec3}; use wsg_lib::AppHandler; use wsg_lib::app::AppBuilder; use wsg_lib::resources::{Geometry, Texture}; @@ -123,6 +123,18 @@ impl AppHandler for Cube { .create_mesh("cube_mesh", cube_geometry(), Some("cube_material")) .unwrap(); app.scene.add_entity("cube", "cube_mesh").unwrap(); + + // Étape 12 (Phase 4.2) : en plus de la lumière directionnelle par défaut (+Z), on ajoute + // une lumière **ponctuelle** chaude devant le cube. Son halo (atténuation linéaire dans le + // rayon) est visible sur la face proche du cube, en superposition à l'éclairage directionnel. + app.scene + .add_point_light( + Vec3::new(1.0, 0.5, 1.5), // position monde, devant/droite du cube + [1.0, 0.7, 0.3], // teinte chaude + 1.0, // intensité + 3.0, // rayon d'atténuation + ) + .unwrap(); } fn update(&mut self, app: &mut wsg_lib::App) { diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 1be52b2..387fbc8 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -23,7 +23,7 @@ use crate::core::Frame; use crate::math::Transform; use crate::pipeline::{DEPTH_FORMAT, create_uniform_bind_group_layouts}; use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE}; -use crate::resources::{Camera, FrameUniforms, Material, Mesh, ObjectUniform}; +use crate::resources::{Camera, FrameUniforms, Lights, Material, Mesh, ObjectUniform}; use crate::scene::Scene; use glam::Vec4; use std::cell::RefCell; @@ -184,20 +184,31 @@ impl Renderer { self.format = format; } - /// Rewrites the shared per-frame uniform buffer from the scene's active camera and the current - /// viewport aspect, then returns the frame bind group wired to that buffer. Called at the start of - /// every `render_scene` so the GPU sees the latest camera matrices and camera position (Étape 4.3). + /// Rewrites the shared per-frame uniform buffer from the scene's active camera, its global + /// light list, its ambient color, and the current viewport aspect, then returns the frame bind + /// group wired to that buffer. Called at the start of every `render_scene` so the GPU sees the + /// latest camera matrices, camera position, and lighting (Étape 4.3, Étape 12). /// - /// The directional light stays at the `FrameUniforms::default()` values (white, along +Z) — scene - /// lighting configuration is a later step; only the camera-driven fields are derived from `camera`. - /// Inputs: camera (the scene's active camera), aspect (viewport width / height). - fn write_frame_uniforms(&self, camera: &Camera, aspect: f32) { + /// The light array is packed via `Lights::into_frame_array` (directionals first, then point + /// lights). Inputs: camera (the scene's active camera), lights (the scene's global light list), + /// ambient (the scene's ambient hemisphere color, rgb), aspect (viewport width / height). + fn write_frame_uniforms( + &self, + camera: &Camera, + lights: &Lights, + ambient: [f32; 3], + aspect: f32, + ) { + let (light_array, num_directional, num_point) = lights.into_frame_array(); let frame = FrameUniforms { view: camera.view_matrix(), proj: camera.projection_matrix(aspect), cam_pos: camera.position.extend(1.0), - light_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), - light_color: Vec4::ONE, + ambient: Vec4::new(ambient[0], ambient[1], ambient[2], 1.0), + lights: light_array, + num_directional, + num_point, + _pad: [0, 0], options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], }; self.queue @@ -268,7 +279,7 @@ impl Renderer { /// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU /// receives the active camera's view/projection matrices and position for this frame (Étape 4.3). pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) { - self.write_frame_uniforms(scene.camera(), aspect); + self.write_frame_uniforms(scene.camera(), scene.lights(), scene.ambient(), aspect); let mut encoder = self .device diff --git a/lib/src/resources/README.md b/lib/src/resources/README.md index c4bb415..5d0b644 100644 --- a/lib/src/resources/README.md +++ b/lib/src/resources/README.md @@ -10,6 +10,8 @@ The `resources` module defines three immutable data types that flow through the | **mesh** | Mesh struct — persistent GPU geometry container with retained CPU `geometry: Arc` (Étape 8), vertex_buffer (wgpu::Buffer), optional index_buffer, and draw call counters. Created via Mesh::from_geometry() which derives Vertex arrays from the Geometry and uploads them to GPU buffers. | | **material** | Material struct — lightweight appearance descriptor pairing shader_id with a shared RenderPipeline Arc. Multiple Materials referencing the same shader_id point to the identical compiled GPU pipeline. Optionally holds a diffuse `Texture` (Étape 10) plus its texture bind group. | | **texture** | Texture struct *(Étape 10)* — GPU 2D image (device, view, sampler) in `Rgba8UnormSrgb`. Constructors: `from_rgba8` (raw bytes), `from_bytes` (encoded, via the `image` crate: png/jpeg/...), `from_file`, and `white_placeholder` (1x1 white used when no texture is attached). Sampler is linear-filtered with repeat addressing. | +| **uniform** | `FrameUniforms` (per-frame uniforms: camera, ambient, global light list, options — 576 B, `Pod`) and `ObjectUniform` (per-entity model matrix — 64 B). Also `Light` (48 B) and `MAX_LIGHTS` (Phase 4.2, Étape 12). | +| **lights** | `Lights` — the scene's CPU-side global light list (directional + point) and its `into_frame_array` packing (Phase 4.2, Étape 12). | ## Interaction with Other Modules diff --git a/lib/src/resources/lights.rs b/lib/src/resources/lights.rs new file mode 100644 index 0000000..0f1cda4 --- /dev/null +++ b/lib/src/resources/lights.rs @@ -0,0 +1,132 @@ +//! # Lights Module — CPU-side Global Light List (Phase 4.2, Étape 12) +//! +//! Holds the scene's global light list — directional + point lights — in a CPU-side [`Lights`] +//! group. The list is uploaded into the per-frame [`FrameUniforms`] uniform array each frame by +//! `Renderer::write_frame_uniforms`. Lights are **global to the scene**: every entity is lit by +//! the same list (per-material lights are out of scope, a later performance/feature step). +//! +//! ## Rangement (no type flag) +//! Directional lights occupy indices `0..num_directional`; point lights occupy +//! `num_directional..num_directional + num_point`. The index alone disambiguates the type in the +//! fragment shader, so no type field is stored in [`Light`]. +//! +//! ## Non-régression +//! [`Lights::default()`] = one white directional light along +Z, which (combined with a white +//! ambient) reproduces exactly the pre-multi-light rendering of `standard_shader.wgsl`. + +use crate::resources::uniform::{Light, MAX_LIGHTS}; +use glam::{Vec3, Vec4}; + +/// The scene's global light list: directional lights (first) and point lights (after). +/// Total capacity is bounded by `MAX_LIGHTS`; adding beyond it is rejected by the `Scene` API. +#[derive(Clone, PartialEq)] +pub struct Lights { + /// Directional lights (indices `0..len` in the frame array). + pub directional: Vec, + /// Point lights (indices `num_directional..` in the frame array). + pub point: Vec, +} + +impl Lights { + /// Default = one white directional light along +Z (from surface toward light), no point lights. + /// This reproduces the historical single-light look when combined with a white ambient. + pub fn new() -> Self { + Self { + directional: vec![Light { + position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z + color: Vec4::ONE, + radius: Vec4::ZERO, + }], + point: Vec::new(), + } + } + + /// Total number of lights (directional + point). + pub fn len(&self) -> usize { + self.directional.len() + self.point.len() + } + + /// `true` when there are no lights at all. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), + /// then point lights. The tail is zero-filled. Returns `(array, num_directional, num_point)`. + /// Caller must ensure `len() <= MAX_LIGHTS` (the `Scene` API validates capacity). + pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32) { + let empty = Light { + position_dir: Vec4::ZERO, + color: Vec4::ZERO, + radius: Vec4::ZERO, + }; + let mut array = [empty; MAX_LIGHTS]; + for (i, l) in self.directional.iter().enumerate() { + array[i] = *l; + } + let n_dir = self.directional.len(); + for (i, l) in self.point.iter().enumerate() { + array[n_dir + i] = *l; + } + (array, n_dir as u32, self.point.len() as u32) + } +} + +impl Default for Lights { + /// `Lights::new()` — one white directional light along +Z (non-regression default). + fn default() -> Self { + Self::new() + } +} + +/// Builds a directional [`Light`] from a direction (from surface toward the light), a color and +/// an intensity multiplier. Used by `Scene::add_directional_light`. +pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light { + Light { + position_dir: dir.extend(0.0), + color: Vec4::new(color[0], color[1], color[2], intensity), + radius: Vec4::ZERO, + } +} + +/// Builds a point [`Light`] from a world position, a color, an intensity multiplier and an +/// attenuation radius (linear falloff to zero at the radius). Used by `Scene::add_point_light`. +pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> Light { + Light { + position_dir: pos.extend(0.0), + color: Vec4::new(color[0], color[1], color[2], intensity), + radius: Vec4::new(radius, 0.0, 0.0, 0.0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_has_one_directional() { + let lights = Lights::new(); + assert_eq!(lights.directional.len(), 1); + assert_eq!(lights.point.len(), 0); + assert_eq!(lights.len(), 1); + } + + #[test] + fn into_frame_array_packs_directional_then_point() { + let mut lights = Lights::new(); // 1 directional + lights + .point + .push(point_light(Vec3::ONE, [1.0, 0.0, 0.0], 1.0, 2.0)); + let (array, n_dir, n_point) = lights.into_frame_array(); + assert_eq!(n_dir, 1); + assert_eq!(n_point, 1); + // Directional first, point after. + assert_eq!(array[0].color, Vec4::ONE); + assert_eq!(array[1].color, Vec4::new(1.0, 0.0, 0.0, 1.0)); + } + + #[test] + fn capacity_bounded_by_max_lights() { + assert!(MAX_LIGHTS >= 1); + } +} diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index f4cf34d..a225600 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -13,6 +13,7 @@ //! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization. pub mod camera; +pub mod lights; pub mod material; pub mod mesh; pub mod texture; @@ -21,10 +22,11 @@ pub mod vertex; // Re-exports pub use camera::Camera; +pub use lights::Lights; pub use material::Material; pub use mesh::Mesh; pub use texture::{Texture, TextureError}; -pub use uniform::{FrameUniforms, ObjectUniform}; +pub use uniform::{FrameUniforms, Light, MAX_LIGHTS, ObjectUniform}; pub use vertex::Vertex; // Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 1bb4a60..63e21db 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -21,10 +21,32 @@ pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::() as u64 /// Byte size of the per-object uniform buffer (`ObjectUniform`). pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::() as u64; -/// Per-frame GPU uniforms : camera matrices + directional light + options. +/// Maximum number of lights stored in the per-frame uniform buffer. +/// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation). +pub const MAX_LIGHTS: usize = 8; + +/// A single light, stored in the per-frame uniform array. One struct serves both types; the +/// *position in the array* disambiguates: indices `0..num_directional` are directional +/// (`position_dir.xyz` = direction **from the surface toward the light**), indices +/// `num_directional..` are point (`position_dir.xyz` = world position). No type flag in the struct. +/// +/// 3 × Vec4 = 48 bytes, 16-byte aligned (std140-compatible with the WGSL `struct Light`). +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable, PartialEq)] +pub struct Light { + /// xyz = direction from surface toward the light (directional) or world position (point); w = 0. + pub position_dir: Vec4, + /// rgb = color; a = intensity (multiplier). + pub color: Vec4, + /// x = attenuation radius (point lights); 0 for directional. + pub radius: Vec4, +} + +/// Per-frame GPU uniforms : camera matrices + ambient + global light list + options. /// /// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there). -/// 192 bytes, 16-byte aligned, no padding — `Pod` for direct `bytes_of` upload. +/// 192 + 48·MAX_LIGHTS bytes (16-byte aligned) — `Pod` for direct `bytes_of` upload. The bind-group +/// layout uses `min_binding_size: None`, so extending this struct is transparent (no relayout). #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] pub struct FrameUniforms { @@ -34,23 +56,38 @@ pub struct FrameUniforms { pub proj: Mat4, /// Camera world position (`.xyz` used). Offset 128. pub cam_pos: Vec4, - /// Directional light direction : points **from the surface toward the light**. Offset 144. - pub light_dir: Vec4, - /// Directional light color (`.rgb` used). Offset 160. - pub light_color: Vec4, - /// Options. `options[0]` = unlit flag (1 → flat color, no directional lighting). Offset 176. + /// Ambient hemisphere color (`.rgb` used). Offset 144. + pub ambient: Vec4, + /// Global light list: `0..num_directional` directional, then `num_point` point. Offset 160. + pub lights: [Light; MAX_LIGHTS], + /// Number of active directional lights (indices `0..num_directional`). Offset 160 + 48·MAX_LIGHTS. + pub num_directional: u32, + /// Number of active point lights (indices after the directionals). + pub num_point: u32, + /// Padding so `options` lands on a 16-byte boundary — matching the WGSL `vec4` + /// (alignment 16), which Rust's `repr(C)` would otherwise place at offset 552. + pub _pad: [u32; 2], + /// Options. `options[0]` = unlit flag (1 → flat color, no lighting). pub options: [u32; 4], } impl Default for FrameUniforms { - /// Sensible defaults : identity camera, white light along +Z, *lit* mode. + /// Sensible defaults : identity camera, white ambient, a single white directional light along + /// +Z (from surface toward light), *lit* mode — reproduces the pre-multi-light look exactly. fn default() -> Self { Self { view: Mat4::IDENTITY, proj: Mat4::IDENTITY, cam_pos: Vec4::ZERO, - light_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), - light_color: Vec4::ONE, + ambient: Vec4::ONE, + lights: [Light { + position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z + color: Vec4::ONE, + radius: Vec4::ZERO, + }; MAX_LIGHTS], + num_directional: 1, + num_point: 0, + _pad: [0, 0], options: [0, 0, 0, 0], } } @@ -75,18 +112,29 @@ mod tests { #[test] fn frame_uniforms_layout_matches_wgsl() { // The offsets below must match the offset table in standard_shader.wgsl. - assert_eq!(size_of::(), 192); + // Header (view..ambient) = 160, lights = 48·MAX_LIGHTS, then counters(8) + pad(8) + + // options(16) = 32. Total = 160 + 48·MAX_LIGHTS + 32 = 576 bytes. + assert_eq!(size_of::(), 160 + 48 * MAX_LIGHTS + 32); assert_eq!(align_of::(), 16); let f = FrameUniforms::default(); assert_eq!(offset_of!(FrameUniforms, view), 0); assert_eq!(offset_of!(FrameUniforms, proj), 64); assert_eq!(offset_of!(FrameUniforms, cam_pos), 128); - assert_eq!(offset_of!(FrameUniforms, light_dir), 144); - assert_eq!(offset_of!(FrameUniforms, light_color), 160); - assert_eq!(offset_of!(FrameUniforms, options), 176); - // Default is lit mode (unlit flag cleared). + assert_eq!(offset_of!(FrameUniforms, ambient), 144); + assert_eq!(offset_of!(FrameUniforms, lights), 160); + assert_eq!( + offset_of!(FrameUniforms, num_directional), + 160 + 48 * MAX_LIGHTS + ); + assert_eq!( + offset_of!(FrameUniforms, options), + 160 + 48 * MAX_LIGHTS + 16 + ); + // Default is lit mode (unlit flag cleared), one directional light, no point lights. assert_eq!(f.options[0], 0); + assert_eq!(f.num_directional, 1); + assert_eq!(f.num_point, 0); } #[test] diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 39f39d1..6a5a6b6 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -17,8 +17,9 @@ use crate::math::{Geometry, Transform}; use crate::pipeline::PipelineCache; -use crate::resources::{Camera, Material, Mesh, Texture}; +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; @@ -58,6 +59,11 @@ pub struct Scene { /// 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>>, + /// 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], } impl Scene { @@ -74,6 +80,8 @@ impl Scene { camera: Camera::default(), gpu: None, default_material: RefCell::new(None), + lights: Lights::new(), + ambient: [1.0, 1.0, 1.0], } } @@ -248,6 +256,86 @@ impl Scene { &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(()) + } + + /// 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 + } + + /// Removes all lights (neither directional nor point). 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(), + }; + } + + /// 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. diff --git a/lib/src/shaders/README.md b/lib/src/shaders/README.md index fa6929c..5b1d3e2 100644 --- a/lib/src/shaders/README.md +++ b/lib/src/shaders/README.md @@ -14,7 +14,7 @@ unlit** de `standard` (décision actée dans le DRAFT : « 2D ⊂ 3D »). | File | Purpose | |------|---------| -| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + directional diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1)). | +| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + multi-light (directional + point) diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1) + texture @group(2)). | ## Shader Contract (standard_shader.wgsl) @@ -34,14 +34,18 @@ par tout matériau (Étape 3 : un seul layout pour tous). | Group / Binding | Struct | Contenu | |-----------------|--------|---------| -| `@group(0) @binding(0)` | `FrameUniforms` (192 B) | `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options` (.x = unlit flag) | +| `@group(0) @binding(0)` | `FrameUniforms` (576 B) | `view`, `proj`, `cam_pos`, `ambient`, `lights[8]`, `num_directional`, `num_point`, `options` (.x = unlit flag) | | `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (matrice modèle de l'entité) | -`light_dir` pointe de la surface vers la lumière ; le fragment shader l'inverse pour le terme N·L. +`FrameUniforms` porte une **liste de lumières globales** (Étape 12, Phase 4.2) : `lights[0..num_directional]` +sont des lumières **directionnelles** (`position_dir.xyz` = direction de la surface vers la lumière), et +`lights[num_directional..]` des lumières **ponctuelles** (`position_dir.xyz` = position monde, `radius.x` = +rayon d'atténuation linéaire). L'index disambiguise le type — pas de drapeau. `ambient` est la couleur du +terme ambiant hémisphérique. ### Mode unlit -Un flag `options.x != 0` neutralise la directionnelle et renvoie la couleur du vertex telle quelle +Un flag `options.x != 0` neutralise **toutes les lumières** et renvoie la couleur du vertex telle quelle (couleur plate). Côté API, `Renderer::set_unlit(true)` (ou `app.renderer_mut().set_unlit(true)`) positionne ce flag dans les frame uniforms. Ainsi le rendu 2D plat est un **cas particulier** de la 3D éclairée. diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index 67fab00..846eaf7 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -8,24 +8,27 @@ //! //! ## Uniform Contract //! Three bind groups, shared by every material (one single pipeline layout — voir Étape 3) : -//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights) [192 bytes] +//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights) [192 + 48·MAX_LIGHTS bytes] //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes] //! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10) //! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d) (Étape 10) //! -//! `FrameUniforms` layout (std140 — each element 16-byte aligned, no padding) : -//! | Offset | Field | Type | Meaning | -//! |--------|--------------|-----------|-----------------------------------| -//! | 0 | view | mat4x4 | Camera view matrix | -//! | 64 | proj | mat4x4 | Camera projection matrix | -//! | 128 | cam_pos | vec4 | Camera world position (.xyz) | -//! | 144 | light_dir | vec4 | Light direction (see below) | -//! | 160 | light_color | vec4 | Light color (.rgb) | -//! | 176 | options | vec4 | x = unlit flag (1 => flat color) | -//! | 192 | total | | | +//! `FrameUniforms` layout (std140 — each element 16-byte aligned) : +//! | Offset | Field | Type | Meaning | +//! |-----------------------|----------------|---------------|----------------------------------| +//! | 0 | view | mat4x4 | Camera view matrix | +//! | 64 | proj | mat4x4 | Camera projection matrix | +//! | 128 | cam_pos | vec4 | Camera world position (.xyz) | +//! | 144 | ambient | vec4 | Ambient hemisphere color (.rgb) | +//! | 160 | lights[0..MAX] | array | Global light list | +//! | 160 + 48·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) | +//! | | num_point | u32 | # point (indices n..) | +//! | | options | vec4 | x = unlit flag (1 => flat color) | //! -//! `light_dir` convention : vector pointing **from the surface toward the light**. -//! The fragment shader negates it to obtain the light direction for the N·L term. +//! `MAX_LIGHTS = 8`. `struct Light` is 48 bytes (3 × vec4). Directional lights occupy +//! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the +//! light**); point lights occupy `lights[num_directional..]` (`position_dir.xyz` = world position, +//! `radius.x` = linear attenuation radius). No type flag — the index disambiguates (Étape 12). //! //! ## Texturing (Étape 10, D2) //! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the @@ -52,13 +55,28 @@ struct VertexInput { @location(3) color: vec4, }; +// Étape 12 (Phase 4.2) : maximum number of lights in the per-frame array. Must match +// `wsg_lib::resources::MAX_LIGHTS`. +const MAX_LIGHTS: u32 = 8u; + +// A single light (48 bytes = 3 × vec4). Directional: `position_dir.xyz` = direction from the +// surface toward the light. Point: `position_dir.xyz` = world position, `radius.x` = linear +// attenuation radius. The array index disambiguates the type (no flag stored). +struct Light { + position_dir: vec4, + color: vec4, // rgb = color; a = intensity + radius: vec4, // x = point-light attenuation radius +}; + struct FrameUniforms { view: mat4x4, proj: mat4x4, cam_pos: vec4, - light_dir: vec4, - light_color: vec4, - options: vec4, // .x : unlit flag (1 = flat color, no directional lighting) + ambient: vec4, // .rgb = ambient hemisphere color + lights: array, // [0..num_directional] directional, then point + num_directional: u32, + num_point: u32, + options: vec4, // .x : unlit flag (1 = flat color, no lighting) }; struct ObjectUniform { @@ -115,16 +133,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } let n = normalize(in.normal); - // light_dir pointe de la surface vers la lumière ; on inverse pour le terme N·L. - let l = normalize(-frame.light_dir.xyz); - let ndotl = max(dot(n, l), 0.0); - // Ambient hémisphérique : dépend de la composante verticale de la normale. + // Ambient hémisphérique : dépend de la composante verticale de la normale (couleur venue + // de frame.ambient, Étape 12 — était codée en dur via la couleur de la lumière avant). let sky = max(n.y, 0.0); - let ambient = frame.light_color.rgb * (0.3 + 0.4 * sky); + let ambient = frame.ambient.rgb * (0.3 + 0.4 * sky); - // Diffuse directionnel classique. - let diffuse = frame.light_color.rgb * ndotl; + var diffuse = vec3(0.0); + + // Lumières directionnelles (indices 0..num_directional). `position_dir` pointe de la surface + // vers la lumière ; on l'inverse pour le terme N·L. + for (var i = 0u; i < frame.num_directional; i++) { + let l = normalize(-frame.lights[i].position_dir.xyz); + let ndotl = max(dot(n, l), 0.0); + diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl; + } + + // Lumières ponctuelles (indices num_directional..num_directional + num_point). Atténuation + // linéaire dans le rayon (zéro au-delà). + for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) { + let to_light = frame.lights[i].position_dir.xyz - in.world_pos; + let dist = length(to_light); + let l = to_light / max(dist, 1e-4); + let ndotl = max(dot(n, l), 0.0); + let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0); + diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff; + } let lit = base * (ambient + diffuse); return vec4(lit, in.color.a);