feat: lumières spot (cône + angle) (Étape 13, Phase 4.2)

- 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<Light> ; 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
This commit is contained in:
Jérôme Bousquié
2026-09-18 19:05:13 +02:00
parent d518948deb
commit e8ff364d0d
11 changed files with 211 additions and 70 deletions
+14
View File
@@ -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) {
+8 -6
View File
@@ -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
+2 -2
View File
@@ -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<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). |
| **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
+69 -21
View File
@@ -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<Light>,
/// Point lights (indices `num_directional..` in the frame array).
pub point: Vec<Light>,
/// Spot lights (indices `num_directional + num_point..` in the frame array).
pub spot: 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.
/// 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]
+40 -21
View File
@@ -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::<ObjectUniform>() 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<u32>`
/// (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::<FrameUniforms>(), 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::<FrameUniforms>(), 160 + 64 * MAX_LIGHTS + 32);
assert_eq!(align_of::<FrameUniforms>(), 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]
+28 -1
View File
@@ -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(),
};
}
+9 -7
View File
@@ -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
+34 -9
View File
@@ -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<f32>) (Étape 10)
@@ -21,14 +21,18 @@
//! | 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) |
//! | 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<u32> | 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<f32>,
color: vec4<f32>, // rgb = color; a = intensity
radius: vec4<f32>, // x = point-light attenuation radius
radius: vec4<f32>, // x = point/spot attenuation radius
dir_angle: vec4<f32>, // spot: xyz = cone axis, w = cos(half-angle)
};
struct FrameUniforms {
@@ -73,9 +80,10 @@ struct FrameUniforms {
proj: mat4x4<f32>,
cam_pos: vec4<f32>,
ambient: vec4<f32>, // .rgb = ambient hemisphere color
lights: array<Light, MAX_LIGHTS>, // [0..num_directional] directional, then point
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
num_directional: u32,
num_point: u32,
num_spot: u32,
options: vec4<u32>, // .x : unlit flag (1 = flat color, no lighting)
};
@@ -160,6 +168,23 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32>(lit, in.color.a);
}