feat: multi-lumières directionnelles + ponctuelles (Étape 12, Phase 4.2)

- Light (48 o) + MAX_LIGHTS=8 ; FrameUniforms étendu (ambient, lights[8], compteurs, _pad)
- resources/lights.rs : Lights (1 dir +Z par défaut, non-régression) + into_frame_array
- Scene : API déclarative add_directional_light / add_point_light / set_ambient / clear_lights
- Renderer::write_frame_uniforms upload les lumières/ambiant de la scène
- standard_shader.wgsl : struct Light + boucles d'accumulation (dir + ponctuelles, atténuation linéaire)
- exemple cube : 1 lumière ponctuelle chaude
- Docs : shaders/resources README, README roadmap, ROADMAP (item coché + Spot notée futur)
- Build/test/fmt OK ; non-régression par défaut (1 dir +Z + ambiant blanc)
This commit is contained in:
Jérôme Bousquié
2026-09-18 18:26:38 +02:00
parent 49ecdea249
commit d518948deb
12 changed files with 394 additions and 70 deletions
+1
View File
@@ -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<Geometry>` (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.) 7. ✅ **CPU geometry storage (Étape 8)** — `Mesh` retains a shared `Arc<Geometry>` (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.) 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.) 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.)
+2 -13
View File
@@ -1,15 +1,4 @@
# DRAFT — Étape suivante # DRAFT — Étape suivante
> 📅 **Document vidé le 2026-09-18** (fin de l'Étape 11, Resize — Phase 4.4, bilan archivé > DRAFT vide. L'Étape 12 (Phase 4.2 — multi-lumières) est terminée et son bilan est archivé dans l'historique git
> dans l'historique git). Ce fichier accueillera le plan de l'étape suivante. > (commit correspondant). Préparer le plan de l'étape suivante ici.
>
> **É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.
+2 -1
View File
@@ -139,7 +139,8 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
### 4.2 Éclairage avancé ### 4.2 Éclairage avancé
- [x] Lumières hémisphériques *(déjà dans le `standard_shader` : mélange hémisphérique, Étape 2)* - [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) - [ ] Shadows (optionnel)
### 4.3 Optimisations ### 4.3 Optimisations
+13 -1
View File
@@ -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 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 //! 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. //! `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::AppHandler;
use wsg_lib::app::AppBuilder; use wsg_lib::app::AppBuilder;
use wsg_lib::resources::{Geometry, Texture}; use wsg_lib::resources::{Geometry, Texture};
@@ -123,6 +123,18 @@ impl AppHandler for Cube {
.create_mesh("cube_mesh", cube_geometry(), Some("cube_material")) .create_mesh("cube_mesh", cube_geometry(), Some("cube_material"))
.unwrap(); .unwrap();
app.scene.add_entity("cube", "cube_mesh").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) { fn update(&mut self, app: &mut wsg_lib::App) {
+22 -11
View File
@@ -23,7 +23,7 @@ use crate::core::Frame;
use crate::math::Transform; use crate::math::Transform;
use crate::pipeline::{DEPTH_FORMAT, create_uniform_bind_group_layouts}; use crate::pipeline::{DEPTH_FORMAT, create_uniform_bind_group_layouts};
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE}; 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 crate::scene::Scene;
use glam::Vec4; use glam::Vec4;
use std::cell::RefCell; use std::cell::RefCell;
@@ -184,20 +184,31 @@ impl Renderer {
self.format = format; self.format = format;
} }
/// Rewrites the shared per-frame uniform buffer from the scene's active camera and the current /// Rewrites the shared per-frame uniform buffer from the scene's active camera, its global
/// viewport aspect, then returns the frame bind group wired to that buffer. Called at the start of /// light list, its ambient color, and the current viewport aspect, then returns the frame bind
/// every `render_scene` so the GPU sees the latest camera matrices and camera position (Étape 4.3). /// 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 /// The light array is packed via `Lights::into_frame_array` (directionals first, then point
/// lighting configuration is a later step; only the camera-driven fields are derived from `camera`. /// lights). Inputs: camera (the scene's active camera), lights (the scene's global light list),
/// Inputs: camera (the scene's active camera), aspect (viewport width / height). /// ambient (the scene's ambient hemisphere color, rgb), aspect (viewport width / height).
fn write_frame_uniforms(&self, camera: &Camera, aspect: f32) { 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 { let frame = FrameUniforms {
view: camera.view_matrix(), view: camera.view_matrix(),
proj: camera.projection_matrix(aspect), proj: camera.projection_matrix(aspect),
cam_pos: camera.position.extend(1.0), cam_pos: camera.position.extend(1.0),
light_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), ambient: Vec4::new(ambient[0], ambient[1], ambient[2], 1.0),
light_color: Vec4::ONE, lights: light_array,
num_directional,
num_point,
_pad: [0, 0],
options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], options: [if self.unlit { 1 } else { 0 }, 0, 0, 0],
}; };
self.queue self.queue
@@ -268,7 +279,7 @@ impl Renderer {
/// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU /// 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). /// 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) { 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 let mut encoder = self
.device .device
+2
View File
@@ -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<Geometry>` (É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. | | **mesh** | Mesh struct — persistent GPU geometry container with retained CPU `geometry: Arc<Geometry>` (É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. | | **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. | | **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 ## Interaction with Other Modules
+132
View File
@@ -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<Light>,
/// Point lights (indices `num_directional..` in the frame array).
pub point: Vec<Light>,
}
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);
}
}
+3 -1
View File
@@ -13,6 +13,7 @@
//! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization. //! - `material::new()` requests RenderPipelines from PipelineCache during scene initialization.
pub mod camera; pub mod camera;
pub mod lights;
pub mod material; pub mod material;
pub mod mesh; pub mod mesh;
pub mod texture; pub mod texture;
@@ -21,10 +22,11 @@ pub mod vertex;
// Re-exports // Re-exports
pub use camera::Camera; pub use camera::Camera;
pub use lights::Lights;
pub use material::Material; pub use material::Material;
pub use mesh::Mesh; pub use mesh::Mesh;
pub use texture::{Texture, TextureError}; pub use texture::{Texture, TextureError};
pub use uniform::{FrameUniforms, ObjectUniform}; pub use uniform::{FrameUniforms, Light, MAX_LIGHTS, ObjectUniform};
pub use vertex::Vertex; pub use vertex::Vertex;
// Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes // Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes
+63 -15
View File
@@ -21,10 +21,32 @@ pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64
/// Byte size of the per-object uniform buffer (`ObjectUniform`). /// Byte size of the per-object uniform buffer (`ObjectUniform`).
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64; pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() 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). /// 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)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)] #[derive(Clone, Copy, Pod, Zeroable)]
pub struct FrameUniforms { pub struct FrameUniforms {
@@ -34,23 +56,38 @@ pub struct FrameUniforms {
pub proj: Mat4, pub proj: Mat4,
/// Camera world position (`.xyz` used). Offset 128. /// Camera world position (`.xyz` used). Offset 128.
pub cam_pos: Vec4, pub cam_pos: Vec4,
/// Directional light direction : points **from the surface toward the light**. Offset 144. /// Ambient hemisphere color (`.rgb` used). Offset 144.
pub light_dir: Vec4, pub ambient: Vec4,
/// Directional light color (`.rgb` used). Offset 160. /// Global light list: `0..num_directional` directional, then `num_point` point. Offset 160.
pub light_color: Vec4, pub lights: [Light; MAX_LIGHTS],
/// Options. `options[0]` = unlit flag (1 → flat color, no directional lighting). Offset 176. /// 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<u32>`
/// (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], pub options: [u32; 4],
} }
impl Default for FrameUniforms { 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 { fn default() -> Self {
Self { Self {
view: Mat4::IDENTITY, view: Mat4::IDENTITY,
proj: Mat4::IDENTITY, proj: Mat4::IDENTITY,
cam_pos: Vec4::ZERO, cam_pos: Vec4::ZERO,
light_dir: Vec4::new(0.0, 0.0, 1.0, 0.0), ambient: Vec4::ONE,
light_color: 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], options: [0, 0, 0, 0],
} }
} }
@@ -75,18 +112,29 @@ mod tests {
#[test] #[test]
fn frame_uniforms_layout_matches_wgsl() { fn frame_uniforms_layout_matches_wgsl() {
// The offsets below must match the offset table in standard_shader.wgsl. // The offsets below must match the offset table in standard_shader.wgsl.
assert_eq!(size_of::<FrameUniforms>(), 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::<FrameUniforms>(), 160 + 48 * MAX_LIGHTS + 32);
assert_eq!(align_of::<FrameUniforms>(), 16); assert_eq!(align_of::<FrameUniforms>(), 16);
let f = FrameUniforms::default(); let f = FrameUniforms::default();
assert_eq!(offset_of!(FrameUniforms, view), 0); assert_eq!(offset_of!(FrameUniforms, view), 0);
assert_eq!(offset_of!(FrameUniforms, proj), 64); assert_eq!(offset_of!(FrameUniforms, proj), 64);
assert_eq!(offset_of!(FrameUniforms, cam_pos), 128); assert_eq!(offset_of!(FrameUniforms, cam_pos), 128);
assert_eq!(offset_of!(FrameUniforms, light_dir), 144); assert_eq!(offset_of!(FrameUniforms, ambient), 144);
assert_eq!(offset_of!(FrameUniforms, light_color), 160); assert_eq!(offset_of!(FrameUniforms, lights), 160);
assert_eq!(offset_of!(FrameUniforms, options), 176); assert_eq!(
// Default is lit mode (unlit flag cleared). 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.options[0], 0);
assert_eq!(f.num_directional, 1);
assert_eq!(f.num_point, 0);
} }
#[test] #[test]
+89 -1
View File
@@ -17,8 +17,9 @@
use crate::math::{Geometry, Transform}; use crate::math::{Geometry, Transform};
use crate::pipeline::PipelineCache; use crate::pipeline::PipelineCache;
use crate::resources::{Camera, Material, Mesh, Texture}; use crate::resources::{Camera, Lights, Material, Mesh, Texture};
use crate::scene::Entity; use crate::scene::Entity;
use glam::Vec3;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; 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 /// 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). /// first call. Interior-mutable so it can be filled from an immutable `&Scene` (used by the Renderer).
default_material: RefCell<Option<Arc<Material>>>, 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],
} }
impl Scene { impl Scene {
@@ -74,6 +80,8 @@ impl Scene {
camera: Camera::default(), camera: Camera::default(),
gpu: None, gpu: None,
default_material: RefCell::new(None), default_material: RefCell::new(None),
lights: Lights::new(),
ambient: [1.0, 1.0, 1.0],
} }
} }
@@ -248,6 +256,86 @@ impl Scene {
&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(())
}
/// 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. /// 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. /// 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. /// Called during scene initialization when building the resource depot.
+8 -4
View File
@@ -14,7 +14,7 @@ unlit** de `standard` (décision actée dans le DRAFT : « 2D ⊂ 3D »).
| File | Purpose | | 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) ## 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 / 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é) | | `@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 ### 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)`) (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 positionne ce flag dans les frame uniforms. Ainsi le rendu 2D plat est un **cas particulier** de la 3D
éclairée. éclairée.
+53 -19
View File
@@ -8,24 +8,27 @@
//! //!
//! ## Uniform Contract //! ## Uniform Contract
//! Three bind groups, shared by every material (one single pipeline layout — voir Étape 3) : //! 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(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10) //! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10) //! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
//! //!
//! `FrameUniforms` layout (std140 — each element 16-byte aligned, no padding) : //! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
//! | Offset | Field | Type | Meaning | //! | Offset | Field | Type | Meaning |
//! |--------|--------------|-----------|-----------------------------------| //! |-----------------------|----------------|---------------|----------------------------------|
//! | 0 | view | mat4x4<f32> | Camera view matrix | //! | 0 | view | mat4x4<f32> | Camera view matrix |
//! | 64 | proj | mat4x4<f32> | Camera projection matrix | //! | 64 | proj | mat4x4<f32> | Camera projection matrix |
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) | //! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
//! | 144 | light_dir | vec4<f32> | Light direction (see below) | //! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
//! | 160 | light_color | vec4<f32> | Light color (.rgb) | //! | 160 | lights[0..MAX] | array<Light> | Global light list |
//! | 176 | options | vec4<u32> | x = unlit flag (1 => flat color) | //! | 160 + 48·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) |
//! | 192 | total | | | //! | | num_point | u32 | # point (indices n..) |
//! | | options | vec4<u32> | x = unlit flag (1 => flat color) |
//! //!
//! `light_dir` convention : vector pointing **from the surface toward the light**. //! `MAX_LIGHTS = 8`. `struct Light` is 48 bytes (3 × vec4). Directional lights occupy
//! The fragment shader negates it to obtain the light direction for the N·L term. //! `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) //! ## Texturing (Étape 10, D2)
//! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the //! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the
@@ -52,13 +55,28 @@ struct VertexInput {
@location(3) color: vec4<f32>, @location(3) color: vec4<f32>,
}; };
// É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<f32>,
color: vec4<f32>, // rgb = color; a = intensity
radius: vec4<f32>, // x = point-light attenuation radius
};
struct FrameUniforms { struct FrameUniforms {
view: mat4x4<f32>, view: mat4x4<f32>,
proj: mat4x4<f32>, proj: mat4x4<f32>,
cam_pos: vec4<f32>, cam_pos: vec4<f32>,
light_dir: vec4<f32>, ambient: vec4<f32>, // .rgb = ambient hemisphere color
light_color: vec4<f32>, lights: array<Light, MAX_LIGHTS>, // [0..num_directional] directional, then point
options: vec4<u32>, // .x : unlit flag (1 = flat color, no directional lighting) num_directional: u32,
num_point: u32,
options: vec4<u32>, // .x : unlit flag (1 = flat color, no lighting)
}; };
struct ObjectUniform { struct ObjectUniform {
@@ -115,16 +133,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
} }
let n = normalize(in.normal); 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 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. var diffuse = vec3<f32>(0.0);
let diffuse = frame.light_color.rgb * ndotl;
// 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); let lit = base * (ambient + diffuse);
return vec4<f32>(lit, in.color.a); return vec4<f32>(lit, in.color.a);