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:
@@ -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.)
|
||||
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.)
|
||||
|
||||
+2
-13
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
+22
-11
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
| **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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
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).
|
||||
/// 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<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],
|
||||
}
|
||||
|
||||
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::<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);
|
||||
|
||||
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]
|
||||
|
||||
+89
-1
@@ -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<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 {
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<f32>) (Étape 10)
|
||||
//!
|
||||
//! `FrameUniforms` layout (std140 — each element 16-byte aligned, no padding) :
|
||||
//! | Offset | Field | Type | Meaning |
|
||||
//! |--------|--------------|-----------|-----------------------------------|
|
||||
//! | 0 | view | mat4x4<f32> | Camera view matrix |
|
||||
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
|
||||
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
|
||||
//! | 144 | light_dir | vec4<f32> | Light direction (see below) |
|
||||
//! | 160 | light_color | vec4<f32> | Light color (.rgb) |
|
||||
//! | 176 | options | vec4<u32> | x = unlit flag (1 => flat color) |
|
||||
//! | 192 | total | | |
|
||||
//! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
|
||||
//! | Offset | Field | Type | Meaning |
|
||||
//! |-----------------------|----------------|---------------|----------------------------------|
|
||||
//! | 0 | view | mat4x4<f32> | Camera view matrix |
|
||||
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
|
||||
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
|
||||
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
|
||||
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
|
||||
//! | 160 + 48·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) |
|
||||
//! | | 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**.
|
||||
//! 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<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 {
|
||||
view: mat4x4<f32>,
|
||||
proj: mat4x4<f32>,
|
||||
cam_pos: vec4<f32>,
|
||||
light_dir: vec4<f32>,
|
||||
light_color: vec4<f32>,
|
||||
options: vec4<u32>, // .x : unlit flag (1 = flat color, no directional lighting)
|
||||
ambient: vec4<f32>, // .rgb = ambient hemisphere color
|
||||
lights: array<Light, MAX_LIGHTS>, // [0..num_directional] directional, then point
|
||||
num_directional: u32,
|
||||
num_point: u32,
|
||||
options: vec4<u32>, // .x : unlit flag (1 = flat color, no lighting)
|
||||
};
|
||||
|
||||
struct ObjectUniform {
|
||||
@@ -115,16 +133,32 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
}
|
||||
|
||||
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<f32>(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<f32>(lit, in.color.a);
|
||||
|
||||
Reference in New Issue
Block a user