From e8ff364d0df335b6478c3849bb8c6486b5c7bd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Fri, 18 Sep 2026 19:05:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lumi=C3=A8res=20spot=20(c=C3=B4ne=20+?= =?UTF-8?q?=20angle)=20(=C3=89tape=2013,=20Phase=204.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Light étendu à 4×Vec4 (64 o) : dir_angle (axe du cône + cos demi-angle) - FrameUniforms 576→704 o : compteur num_spot, _pad[1] - Lights + spot: Vec ; into_frame_array renvoie (arr, n_dir, n_point, n_spot) - Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle) ; clear_lights inclut spot - standard_shader.wgsl : 3e boucle d'accumulation (pénombre lissée ±0.1 rad + atténuation linéaire) - exemple cube : lumière spot verte pointée vers le cube - Docs : shaders/resources README (704 B), README roadmap (item 11), ROADMAP (item coché) - Build/test/fmt OK (5 tests + validation WGSL + doctests) ; non-régression par défaut --- README.md | 1 + docs/DRAFT.md | 7 ++- docs/ROADMAP.md | 2 +- lib/examples/cube.rs | 14 +++++ lib/src/core/renderer.rs | 14 +++-- lib/src/resources/README.md | 4 +- lib/src/resources/lights.rs | 90 +++++++++++++++++++++------- lib/src/resources/uniform.rs | 61 ++++++++++++------- lib/src/scene/scene.rs | 29 ++++++++- lib/src/shaders/README.md | 16 ++--- lib/src/shaders/standard_shader.wgsl | 43 ++++++++++--- 11 files changed, 211 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index b6396a3..f6832f2 100644 --- a/README.md +++ b/README.md @@ -187,3 +187,4 @@ The architecture docs live in `docs/tech/` and are written in **French**. Each d 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.) +11. ✅ **Spot lights (Étape 13, Phase 4.2)** — spot lights (oriented cone + half-angle) added on top of the multi-lighting system. `Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)` registers a spot light; the `standard` fragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation. `Light` grew from 48 to 64 bytes (added `dir_angle`); `FrameUniforms` from 576 to 704 bytes (added `num_spot`). Non-regression: default scene unchanged. The `cube` example adds a green spot light aimed at the cube. (Done 2026-09-18.) diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 5b76e14..a5c8d0d 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -1,4 +1,7 @@ # DRAFT — Étape suivante -> 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. +> DRAFT vide. L'Étape 13 (Phase 4.2 — lumières spot) est terminée et son bilan est archivé dans +> l'historique git (commit correspondant). Préparer le plan de l'étape suivante ici. +> +> Rappel état Phase 4.2 : hémisphérique ✅, multi-lumières (dir + point) ✅ (Étape 12), spot ✅ +> (Étape 13). Reste optionnel : **Shadows**. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 841b512..8703838 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -140,7 +140,7 @@ 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)* - [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)* +- [x] Lumières spot (cône + angle) *(Étape 13, 2026-09-18 : même struct `Light` + champ `dir_angle` (axe du cône + cos du demi-angle) + compteur `num_spot` ; boucle d'accumulation dédiée dans le shader avec pénombre lissée et atténuation linéaire ; `Scene::add_spot_light`)* - [ ] Shadows (optionnel) ### 4.3 Optimisations diff --git a/lib/examples/cube.rs b/lib/examples/cube.rs index ce0192a..53a781b 100644 --- a/lib/examples/cube.rs +++ b/lib/examples/cube.rs @@ -135,6 +135,20 @@ impl AppHandler for Cube { 3.0, // rayon d'atténuation ) .unwrap(); + + // Étape 13 (Phase 4.2) : une lumière **spot** verte pointée vers le cube depuis la gauche. + // Le cône (demi-angle ~20°) projette un faisceau orienté sur les faces du cube, avec une + // pénombre lissée au bord et une atténuation linéaire dans le rayon. + app.scene + .add_spot_light( + Vec3::new(-2.0, 1.0, 1.5), // position monde, à gauche/dessus/derrière-caméra + Vec3::new(2.0, -1.0, -1.5).normalize(), // axe du cône, vers le cube (origine) + [0.3, 1.0, 0.4], // teinte verte + 1.2, // intensité + 4.0, // rayon d'atténuation + 0.35, // demi-angle (~20°) en radians + ) + .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 387fbc8..df4ad13 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -187,11 +187,12 @@ impl Renderer { /// 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). + /// latest camera matrices, camera position, and lighting (Étape 4.3, Étapes 12–13). /// - /// 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). + /// The light array is packed via `Lights::into_frame_array` (directionals first, then point, + /// then spot 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, @@ -199,7 +200,7 @@ impl Renderer { ambient: [f32; 3], aspect: f32, ) { - let (light_array, num_directional, num_point) = lights.into_frame_array(); + let (light_array, num_directional, num_point, num_spot) = lights.into_frame_array(); let frame = FrameUniforms { view: camera.view_matrix(), proj: camera.projection_matrix(aspect), @@ -208,7 +209,8 @@ impl Renderer { lights: light_array, num_directional, num_point, - _pad: [0, 0], + num_spot, + _pad: [0], options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], }; self.queue diff --git a/lib/src/resources/README.md b/lib/src/resources/README.md index 5d0b644..304fd8c 100644 --- a/lib/src/resources/README.md +++ b/lib/src/resources/README.md @@ -10,8 +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). | +| **uniform** | `FrameUniforms` (per-frame uniforms: camera, ambient, global light list, options — 704 B, `Pod`) and `ObjectUniform` (per-entity model matrix — 64 B). Also `Light` (64 B, 4 × vec4, directional/point/spot) and `MAX_LIGHTS` (Phase 4.2, Étapes 12–13). | +| **lights** | `Lights` — the scene's CPU-side global light list (directional + point + spot) and its `into_frame_array` packing (Phase 4.2, Étapes 12–13). | ## Interaction with Other Modules diff --git a/lib/src/resources/lights.rs b/lib/src/resources/lights.rs index 0f1cda4..b0c1799 100644 --- a/lib/src/resources/lights.rs +++ b/lib/src/resources/lights.rs @@ -1,14 +1,15 @@ -//! # Lights Module — CPU-side Global Light List (Phase 4.2, Étape 12) +//! # Lights Module — CPU-side Global Light List (Phase 4.2, Étapes 12–13) //! -//! 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). +//! Holds the scene's global light list — directional, point and spot 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`]. +//! `num_directional..num_directional + num_point`; spot lights occupy +//! `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 @@ -17,33 +18,39 @@ 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. +/// The scene's global light list: directional lights (first), point lights (middle), spot lights +/// (last). 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, + /// Spot lights (indices `num_directional + num_point..` in the frame array). + pub spot: 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. + /// Default = one white directional light along +Z (from surface toward light), no point or + /// spot 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, + dir_angle: Vec4::ZERO, }], point: Vec::new(), + spot: Vec::new(), } } - /// Total number of lights (directional + point). + /// Total number of lights (directional + point + spot). pub fn len(&self) -> usize { - self.directional.len() + self.point.len() + self.directional.len() + self.point.len() + self.spot.len() } /// `true` when there are no lights at all. @@ -51,14 +58,16 @@ impl Lights { 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) { + /// Packs the lights into the GPU frame array: directionals first (`0..num_directional`), then + /// point lights, then spot lights. The tail is zero-filled. Returns + /// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS` + /// (the `Scene` API validates capacity). + pub fn into_frame_array(&self) -> ([Light; MAX_LIGHTS], u32, u32, u32) { let empty = Light { position_dir: Vec4::ZERO, color: Vec4::ZERO, radius: Vec4::ZERO, + dir_angle: Vec4::ZERO, }; let mut array = [empty; MAX_LIGHTS]; for (i, l) in self.directional.iter().enumerate() { @@ -68,7 +77,11 @@ impl Lights { for (i, l) in self.point.iter().enumerate() { array[n_dir + i] = *l; } - (array, n_dir as u32, self.point.len() as u32) + let n_point = self.point.len(); + for (i, l) in self.spot.iter().enumerate() { + array[n_dir + n_point + i] = *l; + } + (array, n_dir as u32, n_point as u32, self.spot.len() as u32) } } @@ -86,6 +99,7 @@ pub fn directional_light(dir: Vec3, color: [f32; 3], intensity: f32) -> Light { position_dir: dir.extend(0.0), color: Vec4::new(color[0], color[1], color[2], intensity), radius: Vec4::ZERO, + dir_angle: Vec4::ZERO, } } @@ -96,6 +110,26 @@ pub fn point_light(pos: Vec3, color: [f32; 3], intensity: f32, radius: f32) -> L 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), + dir_angle: Vec4::ZERO, + } +} + +/// Builds a spot [`Light`] from a world position, a cone axis (from the light toward the scene), a +/// color, an intensity multiplier, an attenuation radius and a half-angle in radians. Used by +/// `Scene::add_spot_light`. The half-angle is stored as its cosine in `dir_angle.w`. +pub fn spot_light( + pos: Vec3, + dir: Vec3, + color: [f32; 3], + intensity: f32, + radius: f32, + half_angle: 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), + dir_angle: dir.normalize().extend(half_angle.cos()), } } @@ -108,21 +142,35 @@ mod tests { let lights = Lights::new(); assert_eq!(lights.directional.len(), 1); assert_eq!(lights.point.len(), 0); + assert_eq!(lights.spot.len(), 0); assert_eq!(lights.len(), 1); } #[test] - fn into_frame_array_packs_directional_then_point() { + fn into_frame_array_packs_directional_point_then_spot() { 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(); + lights.spot.push(spot_light( + Vec3::new(2.0, 0.0, 0.0), + Vec3::new(-1.0, 0.0, 0.0), + [0.0, 1.0, 0.0], + 1.0, + 3.0, + 0.3, + )); + let (array, n_dir, n_point, n_spot) = lights.into_frame_array(); assert_eq!(n_dir, 1); assert_eq!(n_point, 1); - // Directional first, point after. + assert_eq!(n_spot, 1); + // Directional first, point second, spot third. assert_eq!(array[0].color, Vec4::ONE); assert_eq!(array[1].color, Vec4::new(1.0, 0.0, 0.0, 1.0)); + assert_eq!(array[2].color, Vec4::new(0.0, 1.0, 0.0, 1.0)); + // Spot stores the cone axis (normalized) and the half-angle cosine. + assert_eq!(array[2].dir_angle.truncate(), Vec3::new(-1.0, 0.0, 0.0)); + assert!((array[2].dir_angle.w - 0.3_f32.cos()).abs() < 1e-6); } #[test] diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 63e21db..913446d 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -5,7 +5,7 @@ //! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding. //! //! Two bind groups are shared by every pipeline (single-layout decision, Étape 3) : -//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights) → 192 bytes +//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights) → 704 bytes //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) → 64 bytes //! //! ## Interaction with Other Modules @@ -25,28 +25,39 @@ pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::() as u64 /// 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. +/// A single light, stored in the per-frame uniform array. One struct serves all three 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..num_directional + num_point` are **point** +/// (`position_dir.xyz` = world position); +/// - indices `num_directional + num_point..` are **spot** (`position_dir.xyz` = world position, +/// `dir_angle.xyz` = cone axis **from the light toward the scene**, `dir_angle.w` = cos of the +/// half-angle). +/// No type flag in the struct. /// -/// 3 × Vec4 = 48 bytes, 16-byte aligned (std140-compatible with the WGSL `struct Light`). +/// 4 × Vec4 = 64 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. + /// xyz = direction from surface toward the light (directional) or world position (point/spot); + /// w = 0. pub position_dir: Vec4, /// rgb = color; a = intensity (multiplier). pub color: Vec4, - /// x = attenuation radius (point lights); 0 for directional. + /// x = attenuation radius (point/spot lights); 0 for directional. pub radius: Vec4, + /// Spot only: xyz = cone axis (from the light toward the scene), w = cos of the half-angle. + /// Zero for directional and point lights. + pub dir_angle: 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 + 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). +/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then counters + padding + options — +/// total **704 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 { @@ -58,15 +69,19 @@ pub struct FrameUniforms { pub cam_pos: Vec4, /// Ambient hemisphere color (`.rgb` used). Offset 144. pub ambient: Vec4, - /// Global light list: `0..num_directional` directional, then `num_point` point. Offset 160. + /// Global light list: directionals, then point, then spot. Offset 160. pub lights: [Light; MAX_LIGHTS], - /// Number of active directional lights (indices `0..num_directional`). Offset 160 + 48·MAX_LIGHTS. + /// Number of active directional lights (indices `0..num_directional`). + /// Offset 160 + 64·MAX_LIGHTS. pub num_directional: u32, /// Number of active point lights (indices after the directionals). pub num_point: u32, + /// Number of active spot lights (indices after the point lights). + pub num_spot: 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], + /// (alignment 16), which Rust's `repr(C)` would otherwise place too early: three u32 counters + /// occupy 12 bytes, so 4 bytes of padding align `options` to 16. + pub _pad: [u32; 1], /// Options. `options[0]` = unlit flag (1 → flat color, no lighting). pub options: [u32; 4], } @@ -74,6 +89,7 @@ pub struct FrameUniforms { impl Default for FrameUniforms { /// 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. + /// No point or spot lights. fn default() -> Self { Self { view: Mat4::IDENTITY, @@ -84,10 +100,12 @@ impl Default for FrameUniforms { position_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), // from surface toward light = +Z color: Vec4::ONE, radius: Vec4::ZERO, + dir_angle: Vec4::ZERO, }; MAX_LIGHTS], num_directional: 1, num_point: 0, - _pad: [0, 0], + num_spot: 0, + _pad: [0], options: [0, 0, 0, 0], } } @@ -112,9 +130,9 @@ mod tests { #[test] fn frame_uniforms_layout_matches_wgsl() { // The offsets below must match the offset table in standard_shader.wgsl. - // 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); + // Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters(12) + pad(4) + + // options(16) = 32. Total = 160 + 64·MAX_LIGHTS + 32 = 704 bytes. + assert_eq!(size_of::(), 160 + 64 * MAX_LIGHTS + 32); assert_eq!(align_of::(), 16); let f = FrameUniforms::default(); @@ -125,16 +143,17 @@ mod tests { assert_eq!(offset_of!(FrameUniforms, lights), 160); assert_eq!( offset_of!(FrameUniforms, num_directional), - 160 + 48 * MAX_LIGHTS + 160 + 64 * MAX_LIGHTS ); assert_eq!( offset_of!(FrameUniforms, options), - 160 + 48 * MAX_LIGHTS + 16 + 160 + 64 * MAX_LIGHTS + 16 ); - // Default is lit mode (unlit flag cleared), one directional light, no point lights. + // Default is lit mode (unlit flag cleared), one directional light, no point/spot lights. assert_eq!(f.options[0], 0); assert_eq!(f.num_directional, 1); assert_eq!(f.num_point, 0); + assert_eq!(f.num_spot, 0); } #[test] diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 6a5a6b6..a47f882 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -305,6 +305,32 @@ impl Scene { 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) { @@ -317,12 +343,13 @@ impl Scene { &self.lights } - /// Removes all lights (neither directional nor point). The fragment shader then contributes + /// 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(), }; } diff --git a/lib/src/shaders/README.md b/lib/src/shaders/README.md index 5b1d3e2..4b3104e 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 + multi-light (directional + point) diffuse with an explicit unlit mode. Carries the full uniform contract (frame @group(0) + object @group(1) + texture @group(2)). | +| **standard_shader.wgsl** | Standard (Phong) vertex/fragment shader — ambient + multi-light (directional + point + spot) 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,16 @@ par tout matériau (Étape 3 : un seul layout pour tous). | Group / Binding | Struct | Contenu | |-----------------|--------|---------| -| `@group(0) @binding(0)` | `FrameUniforms` (576 B) | `view`, `proj`, `cam_pos`, `ambient`, `lights[8]`, `num_directional`, `num_point`, `options` (.x = unlit flag) | +| `@group(0) @binding(0)` | `FrameUniforms` (704 B) | `view`, `proj`, `cam_pos`, `ambient`, `lights[8]`, `num_directional`, `num_point`, `num_spot`, `options` (.x = unlit flag) | | `@group(1) @binding(0)` | `ObjectUniform` (64 B) | `model` (matrice modèle de l'entité) | -`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. +`FrameUniforms` porte une **liste de lumières globales** (Étapes 12–13, Phase 4.2) : `lights[0..num_directional]` +sont des lumières **directionnelles** (`position_dir.xyz` = direction de la surface vers la lumière), +`lights[num_directional..num_directional + num_point]` des lumières **ponctuelles** +(`position_dir.xyz` = position monde, `radius.x` = rayon d'atténuation linéaire), et +`lights[num_directional + num_point..]` des lumières **spot** (`position_dir.xyz` = position monde, +`dir_angle.xyz` = axe du cône de la lumière vers la scène, `dir_angle.w` = cos du demi-angle). +L'index disambiguise le type — pas de drapeau. `ambient` est la couleur du terme ambiant hémisphérique. ### Mode unlit diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index 846eaf7..395e781 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -8,7 +8,7 @@ //! //! ## 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 + 48·MAX_LIGHTS bytes] +//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights) [704 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) @@ -21,14 +21,18 @@ //! | 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) | +//! | 160 + 64·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) | //! | | num_point | u32 | # point (indices n..) | +//! | | num_spot | u32 | # spot (indices after point) | //! | | options | vec4 | x = unlit flag (1 => flat color) | //! -//! `MAX_LIGHTS = 8`. `struct Light` is 48 bytes (3 × vec4). Directional lights occupy +//! `MAX_LIGHTS = 8`. `struct Light` is 64 bytes (4 × 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). +//! light**); point lights occupy `lights[num_directional..num_directional + num_point]` +//! (`position_dir.xyz` = world position, `radius.x` = linear attenuation radius); spot lights +//! occupy `lights[num_directional + num_point..]` (`position_dir.xyz` = world position, +//! `dir_angle.xyz` = cone axis from the light toward the scene, `dir_angle.w` = cos of the +//! half-angle). No type flag — the index disambiguates (Étapes 12–13). //! //! ## Texturing (Étape 10, D2) //! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the @@ -59,13 +63,16 @@ struct VertexInput { // `wsg_lib::resources::MAX_LIGHTS`. const MAX_LIGHTS: u32 = 8u; -// A single light (48 bytes = 3 × vec4). Directional: `position_dir.xyz` = direction from the +// A single light (64 bytes = 4 × 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). +// attenuation radius. Spot: `position_dir.xyz` = world position, `dir_angle.xyz` = cone axis +// (from the light toward the scene), `dir_angle.w` = cos of the half-angle. 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 + radius: vec4, // x = point/spot attenuation radius + dir_angle: vec4, // spot: xyz = cone axis, w = cos(half-angle) }; struct FrameUniforms { @@ -73,9 +80,10 @@ struct FrameUniforms { proj: mat4x4, cam_pos: vec4, ambient: vec4, // .rgb = ambient hemisphere color - lights: array, // [0..num_directional] directional, then point + lights: array, // directional, then point, then spot num_directional: u32, num_point: u32, + num_spot: u32, options: vec4, // .x : unlit flag (1 = flat color, no lighting) }; @@ -160,6 +168,23 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff; } + // Lumières spot (indices num_directional + num_point..num_directional + num_point + + // num_spot). Cône orienté : pénombre lissée entre le demi-angle intérieur (dir_angle.w) et + // un liseré extérieur (demi-angle − 0.1 rad), plus atténuation linéaire par rayon. + let spot_base = frame.num_directional + frame.num_point; + for (var i = spot_base; i < spot_base + frame.num_spot; 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 cone = dot(l, normalize(frame.lights[i].dir_angle.xyz)); + let cos_inner = frame.lights[i].dir_angle.w; + let cos_outer = cos_inner - 0.1; + let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.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 * spot_factor; + } + let lit = base * (ambient + diffuse); return vec4(lit, in.color.a); }