diff --git a/lib/examples/README.md b/lib/examples/README.md index 4bc323d..e9cc76b 100644 --- a/lib/examples/README.md +++ b/lib/examples/README.md @@ -13,6 +13,7 @@ cargo run -p wsg-lib --example | `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. | | `manual` | `cargo run -p wsg-lib --example manual` | Low-level workflow: `Context`, `Renderer`, `PipelineCache`, `Mesh` used directly (no `App` facade). | | `spot_test` | `cargo run -p wsg-lib --example spot_test` | Spot-light isolation: only one spot is on (near-zero ambient), cube rotates on two axes so the oriented beam is clearly visible. | +| `shadow_test` | `cargo run -p wsg-lib --example shadow_test` | Shadow mapping (Étape 14): one directional light is the shadow caster (`set_shadow_caster(Some(0))`); a cube casts a PCF-softened shadow onto a thin ground slab. | ## Conventions diff --git a/lib/examples/shadow_test.rs b/lib/examples/shadow_test.rs new file mode 100644 index 0000000..e521e40 --- /dev/null +++ b/lib/examples/shadow_test.rs @@ -0,0 +1,132 @@ +//! Dedicated test for **shadow mapping** (Étape 14, Phase 4.2). +//! +//! A single **directional** light is configured as the shadow caster +//! (`Scene::set_shadow_caster(Some(0))`). The cube sits on a large thin ground +//! slab, so its silhouette is projected as a crisp PCF-softened shadow. With a +//! small ambient term the shadow is clearly visible and the light/shadow +//! directions are easy to read: +//! +//! 1. the **blocker** (cube) casts a directional shadow that stretches along +//! the ground opposite the light direction, +//! 2. the shadow edge is **softened** by 3×3 PCF (no hard jagged border), +//! 3. the lit faces are bright while the shadowed ground stays near-ambient, +//! proving the depth comparison is applied per-pixel. +//! +//! Run with: `cargo run -p wsg-lib --example shadow_test` +use glam::{Vec3}; +use wsg_lib::resources::{Camera, Geometry}; +use wsg_lib::utils::WsgError; + +/// Shadow handler: a fixed scene (ground slab + cube blocker) lit by one +/// shadow-casting directional light. +struct ShadowTest; + +/// Axis-aligned box geometry (24 vertices / 36 indices, per-face normals + uvs). +fn box_geometry(hx: f32, hy: f32, hz: f32) -> Geometry { + let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [ + ( + [0.0, 0.0, 1.0], + [[-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]], + ), // +Z + ( + [0.0, 0.0, -1.0], + [[hx, -hy, -hz], [-hx, -hy, -hz], [-hx, hy, -hz], [hx, hy, -hz]], + ), // -Z + ( + [1.0, 0.0, 0.0], + [[hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz]], + ), // +X + ( + [-1.0, 0.0, 0.0], + [[-hx, -hy, hz], [-hx, hy, hz], [-hx, hy, -hz], [-hx, -hy, -hz]], + ), // -X + ( + [0.0, 1.0, 0.0], + [[-hx, hy, -hz], [hx, hy, -hz], [hx, hy, hz], [-hx, hy, hz]], + ), // +Y + ( + [0.0, -1.0, 0.0], + [[-hx, -hy, hz], [hx, -hy, hz], [hx, -hy, -hz], [-hx, -hy, -hz]], + ), // -Y + ]; + + let mut positions = Vec::with_capacity(24); + let mut normals = Vec::with_capacity(24); + let mut uvs = Vec::with_capacity(24); + let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + for (normal, corners) in faces { + for (i, corner) in corners.iter().enumerate() { + positions.push(*corner); + normals.push(normal); + uvs.push(quad_uvs[i]); + } + } + let mut indices = Vec::with_capacity(36); + for face in 0..6u16 { + let b = face * 4; + indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]); + } + Geometry::new(positions) + .with_normals(normals) + .with_uvs(uvs) + .with_indices(indices) +} + +impl wsg_lib::AppHandler for ShadowTest { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + app.scene.add_material_shader("mat", "standard").unwrap(); + + // Ground slab (thin, wide) lying with its top at y = 0. + app.scene + .create_mesh("ground_mesh", box_geometry(5.0, 0.05, 5.0), Some("mat")) + .unwrap(); + app.scene + .add_entity_with_transform( + "ground", + "ground_mesh", + wsg_lib::math::Transform::identity(), + ) + .unwrap(); + + // Blocker cube centred at the origin, standing on the ground (bottom at y = 0). + app.scene + .create_mesh("cube_mesh", box_geometry(0.5, 0.5, 0.5), Some("mat")) + .unwrap(); + let mut cube_tf = wsg_lib::math::Transform::identity(); + cube_tf.translation = Vec3::new(0.0, 0.5, 0.0); + app.scene + .add_entity_with_transform("cube", "cube_mesh", cube_tf) + .unwrap(); + + // One directional light only: replace the default list. + app.scene.clear_lights(); + // Direction "from surface toward the light", i.e. the light source sits up and to + // the -x -z side, so the shadow is cast toward +x +z (toward the camera). + let toward_light = Vec3::new(-0.6, 1.1, -0.6).normalize(); + app.scene + .add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.6) + .unwrap(); + + // Make this directional light (packed index 0) the shadow caster. + app.scene.set_shadow_caster(Some(0)); + + // Small ambient so the shadowed side of the ground stays readable. + app.scene.set_ambient([0.12, 0.12, 0.14]); + + // Slightly elevated view so both the cube and its ground shadow are framed. + app.scene + .set_camera(Camera::new(Vec3::new(3.4, 2.6, 3.4), Vec3::ZERO, Vec3::Y)); + } +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = wsg_lib::app::AppBuilder::new() + .title("WSG Shadow Test") + .build() + .await?; + app.run(ShadowTest) +} diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index df4ad13..d063ff7 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -21,11 +21,19 @@ use crate::core::Context; 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, Lights, Material, Mesh, ObjectUniform}; +use crate::pipeline::{ + DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout, + create_shadow_uniform_layout, create_uniform_bind_group_layouts, +}; +use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE}; +use crate::resources::{ + Camera, FrameUniforms, Lights, Material, Mesh, ObjectUniform, ShadowUniform, MAX_LIGHTS, +}; use crate::scene::Scene; -use glam::Vec4; +use crate::utils::conf::{ + SHADOW_DEPTH_BIAS, SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, +}; +use glam::{Mat4, Vec3, Vec4}; use std::cell::RefCell; use std::collections::HashMap; @@ -67,6 +75,22 @@ pub struct Renderer { /// `FrameUniforms` is set to 1 so the `standard` shader returns vertex colors as-is — flat 2D /// rendering is thus a special case of the 3D lit path (DRAFT Étape 5). Defaults to `false` (lit). unlit: bool, + // Étape 14 (DRAFT 3.2) — shadow mapping resources, owned by the Renderer like the depth texture. + /// Backing GPU shadow-map texture (D2), kept alive for the whole application lifetime. Sized + /// `SHADOW_MAP_SIZE²`, `DEPTH_FORMAT`, used as the shadow pass depth attachment **and** bound + /// for sampling in the main pass (`RENDER_ATTACHMENT | TEXTURE_BINDING`). + _shadow_texture: wgpu::Texture, + /// Depth view of the shadow map, bound into `shadow_bind_group` (group 3) for the PCF test. + shadow_view: wgpu::TextureView, + /// Group-3 bind group (comparison sampler + shadow depth texture) bound on every main draw call. + shadow_bind_group: wgpu::BindGroup, + /// Per-frame uniform buffer holding the shadow-casting light's `view_proj` (D3). Rewritten + /// each frame before the shadow pass so the depth-only pipeline sees the current light pose. + shadow_uniform_buffer: wgpu::Buffer, + /// Group-0 bind group of the shadow pipeline (the light `view_proj`, D4). + shadow_uniform_bind_group: wgpu::BindGroup, + /// Depth-only pipeline rendering the scene from the shadow light's point of view (D4). + shadow_pipeline: wgpu::RenderPipeline, } impl Renderer { @@ -125,6 +149,55 @@ impl Renderer { }], }); + // Étape 14 (DRAFT 3.2) : shadow mapping resources — shadow map texture/view, comparison + // sampler, group-3 bind group, shadow-light uniform buffer + group-0 bind group, and the + // depth-only shadow pipeline. All allocated once here at the default resolution (D2/D8). + let (shadow_texture, shadow_view) = create_shadow_map(&device, SHADOW_MAP_SIZE); + let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("shadow comparison sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + // Comparison sampler : `textureSampleCompare` returns 1 when the sampled depth passes + // this test against the reference, 0 otherwise (D5). GreaterEqual = lit when nothing + // closer than the fragment has been written into the shadow map. + compare: Some(wgpu::CompareFunction::GreaterEqual), + ..Default::default() + }); + let shadow_map_layout = create_shadow_map_bind_group_layout(&device); + let shadow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("shadow map bind group"), + layout: &shadow_map_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Sampler(&shadow_sampler), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&shadow_view), + }, + ], + }); + let shadow_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("shadow uniform buffer"), + size: SHADOW_UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let shadow_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("shadow uniform bind group"), + layout: &create_shadow_uniform_layout(&device), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: shadow_uniform_buffer.as_entire_binding(), + }], + }); + let shadow_pipeline = build_shadow_pipeline(&device, &object_layout); + let renderer = Self { queue, device, @@ -137,6 +210,12 @@ impl Renderer { shared_object_bind_group, object_cache: RefCell::new(HashMap::new()), unlit: false, + _shadow_texture: shadow_texture, + shadow_view, + shadow_bind_group, + shadow_uniform_buffer, + shadow_uniform_bind_group, + shadow_pipeline, }; // Seed the shared frame buffer with an identity camera + current unlit flag so the low-level // `render` path (which has no window/camera) sees coherent values before `render_scene` runs. @@ -151,6 +230,9 @@ impl Renderer { fn write_default_frame_uniforms(&self) { let frame = FrameUniforms { options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], + // Étape 14 (D2) : no active shadow caster in the low-level path — sentinel index + // MAX_LIGHTS disables the shadow term in the shader even if options.y were set. + shadow_light_index: MAX_LIGHTS as u32, ..FrameUniforms::default() }; self.queue @@ -192,15 +274,28 @@ impl Renderer { /// 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). + /// height), shadow_caster (the packed-array index of the shadow-casting light, from + /// `Scene::shadow_caster`, or `None` when shadows are disabled / the caster is a point light). fn write_frame_uniforms( &self, camera: &Camera, lights: &Lights, ambient: [f32; 3], aspect: f32, + shadow_caster: Option, ) { let (light_array, num_directional, num_point, num_spot) = lights.into_frame_array(); + // Étape 14 (DRAFT 3.2) : derive the shadow light's view_proj and shadow flags (D3). + let (shadow_light_index, light_view_proj, shadow_params, shadow_on) = + match self.shadow_light_view_proj(lights, shadow_caster) { + Some((index, vp)) => ( + index as u32, + vp, + Vec4::new(SHADOW_MAP_SIZE as f32, SHADOW_DEPTH_BIAS, 0.0, 0.0), + 1, + ), + None => (MAX_LIGHTS as u32, Mat4::IDENTITY, Vec4::ZERO, 0), + }; let frame = FrameUniforms { view: camera.view_matrix(), proj: camera.projection_matrix(aspect), @@ -210,13 +305,60 @@ impl Renderer { num_directional, num_point, num_spot, - _pad: [0], - options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], + shadow_light_index, + light_view_proj, + shadow_params, + options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0], }; self.queue .write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame)); } + /// Computes the light-space orthographic view-projection of the shadow-casting light, plus its + /// packed-array index. The volume covered is an orthographic box of half-size + /// `SHADOW_SCENE_RADIUS` centered on the scene origin (SHADOW_SCENE_CENTER), oriented so its + /// `-z` axis aligns with the light's travel direction (light → scene). Placing the eye behind + /// the scene along the light path keeps the frustum locked to the light orientation even when + /// the directional light's `position` is arbitrary. The projection uses `near = 0.0` / + /// `far = SHADOW_SCENE_RADIUS` so the depth written by the shadow pass matches the `depth` the + /// main-pass shader compares (D3). Returns `None` when no valid caster is selected (shadows + /// disabled, index out of bounds, or the caster is a point light — D6). + fn shadow_light_view_proj( + &self, + lights: &Lights, + caster: Option, + ) -> Option<(usize, Mat4)> { + let index = caster?; + if index >= lights.len() { + return None; + } + let light = lights.get(index)?; + // Directional and spot lights carry a direction; point lights would need a 6-face cubemap + // shadow, which is out of scope (D6), so we reject them. + let dir = match light.light_type() { + crate::resources::LightType::Directional + | crate::resources::LightType::Spot { .. } => Vec3::new( + light.dir_angle.x, + light.dir_angle.y, + light.dir_angle.z, + ), + crate::resources::LightType::Point => return None, + }; + let r = SHADOW_SCENE_RADIUS; + let target = Vec3::from(SHADOW_SCENE_CENTER); + // Eye one scene-radius behind the target along the light path, so distance(target)=r and + // every point in the box has depth within [near=0, far=r]. + let eye = target - dir * r; + // Avoid a degenerate basis when the light points straight down/up (parallel up vector). + let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y }; + let view = glam::camera::rh::view::look_at_mat4(eye, target, up); + // Orthographic box of half-size r, near 0, far r (D1/D3), in the same OpenGL NDC convention + // as the camera projection (wgpu maps NDC z ∈ [-1,1] to depth [0,1], see standard_shader). + let proj = + glam::camera::rh::proj::opengl::orthographic(-r, r, -r, r, 0.0, r); + Some((index, proj * view)) + } + /// Orchestrates rendering of a single object: binds Material pipeline + Mesh vertex data into a RenderPass, /// then submits commands to the GPU queue for execution. Called per-frame by the orchestrator (main.rs). /// Inputs: view (TextureView color attachment target), mesh (geometry to render), material (shader+pipeline). @@ -265,6 +407,7 @@ impl Renderer { material, &self.frame_bind_group, &self.shared_object_bind_group, + &self.shadow_bind_group, ); } self.queue.submit(std::iter::once(encoder.finish())); @@ -281,7 +424,13 @@ 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(), scene.lights(), scene.ambient(), aspect); + self.write_frame_uniforms( + scene.camera(), + scene.lights(), + scene.ambient(), + aspect, + scene.shadow_caster(), + ); let mut encoder = self .device @@ -289,6 +438,11 @@ impl Renderer { label: Some("scene encoder"), }); + // Étape 14 (DRAFT 3.2) : run the depth-only shadow pass first when a light is configured to + // cast shadows (D4). It populates `shadow_view` on the shared encoder; the main pass below + // then samples it via `shadow_bind_group`. `render_shadow_map` no-ops when shadows are off. + self.render_shadow_map(&mut encoder, scene); + { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("scene render pass"), @@ -328,12 +482,69 @@ impl Renderer { &material, &self.frame_bind_group, &object_bind_group, + &self.shadow_bind_group, ); } } self.queue.submit(std::iter::once(encoder.finish())); } + /// Renders every entity of `scene` from the shadow-casting light's point of view into the + /// shadow depth map (Étape 14, D4), using the dedicated depth-only `shadow_pipeline`. Called at + /// the start of `render_scene`. No-ops (produces no GPU work) when `scene.shadow_caster()` is + /// `None`. The shadow light's `view_proj` is written to `shadow_uniform_buffer`, and the shadow + /// pass writes depth into `shadow_view` (clear 1.0, store). The per-entity model bind groups are + /// reused from `object_bind_group_for`, so transforms match the main pass exactly. + /// Inputs: encoder (the shared command encoder for the frame), scene (entities to cast). + fn render_shadow_map(&self, encoder: &mut wgpu::CommandEncoder, scene: &Scene) { + let _caster = match scene.shadow_caster() { + Some(c) => c, + None => return, + }; + // Recompute the light's view_proj and write it into the shadow uniform buffer so the + // depth-only vertex shader transforms vertices into light-clip space (D4). + let (light_index, vp) = match self.shadow_light_view_proj(scene.lights(), Some(_caster)) { + Some(pair) => pair, + None => return, + }; + let shadow_uniform = ShadowUniform { view_proj: vp }; + self.queue + .write_buffer(&self.shadow_uniform_buffer, 0, bytemuck::bytes_of(&shadow_uniform)); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("shadow map render pass"), + color_attachments: &[], + // Depth-only : the shadow map is the sole attachment. Clear 1.0 so fragments beyond + // `far` read as "fully distant" and never occlude lit surfaces (D4). + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + pass.set_pipeline(&self.shadow_pipeline); + // Group 0 : the shadow light view_proj (D4) — the shadow pipeline's only uniform group. + pass.set_bind_group(0, &self.shadow_uniform_bind_group, &[]); + for (label, mesh, transform) in scene.iter_entities() { + let object_bind_group = self.object_bind_group_for(label, transform); + // Group 1 : per-entity model. The shadow pipeline has no texture/sampler groups. + pass.set_bind_group(1, &object_bind_group, &[]); + pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + if let Some(index_buffer) = &mesh.index_buffer { + pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); + pass.draw_indexed(0..mesh.num_indices, 0, 0..1); + } else { + pass.draw(0..mesh.num_vertices, 0..1); + } + } + drop(pass); + let _ = light_index; // (index retained for future per-light shadow options) + } + /// Presents the rendered frame by submitting the acquired surface texture to the GPU queue. /// The frame must have been obtained via Context::begin_frame() or Frame::try_new(); calling present() /// twice on the same texture is undefined behavior. Called by the orchestrator after render(). @@ -419,21 +630,52 @@ fn create_depth_texture( (depth_texture, depth_view) } -/// Binds a Material pipeline, the three shared bind groups, and Mesh buffers into an active render +/// Allocates the shadow-map texture + view backing the depth-only shadow pass's +/// `depth_stencil_attachment` (Étape 14, D2/D8). Square (`size` x `size`), `DEPTH_FORMAT`, single +/// mip, no MSAA. Unlike the screen depth texture this one is flagged **both** `RENDER_ATTACHMENT` +/// (shadow pass writes depth) **and** `TEXTURE_BINDING` (main pass samples it via the group-3 +/// comparison sampler). Allocated once at the default resolution; resizing is deferred (D8). +/// Inputs: device (GPU resource creator), size (shadow map edge length in pixels). +/// Returns the (texture, view) pair; the caller keeps both alive. +fn create_shadow_map( + device: &wgpu::Device, + size: u32, +) -> (wgpu::Texture, wgpu::TextureView) { + let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow map"), + size: wgpu::Extent3d { + width: size, + height: size, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: DEPTH_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()); + (shadow_texture, shadow_view) +} + +/// Binds a Material pipeline, the four shared bind groups, and Mesh buffers into an active render /// pass and issues the draw call. Shared by `Renderer::render` and `Renderer::render_scene`. -/// The frame (@0), object (@1) and texture (@2) bind groups are **required** by every pipeline layout -/// (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture) — they must be bound even if the -/// shader does not read them. Draws indexed geometry when an index buffer exists, otherwise falls -/// back to a non-indexed draw. +/// The frame (@0), object (@1), texture (@2) and shadow-map (@3) bind groups are **required** by +/// every pipeline layout (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture — Étape 14 : +/// groupe ombre) — they must be bound even if the shader does not read them. Draws indexed geometry +/// when an index buffer exists, otherwise falls back to a non-indexed draw. /// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline + texture bind -/// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/identity -/// model). +/// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/ +/// identity model), shadow_bind_group (reserved group-3 shadow-map bind group, unused by the +/// depth-only shadow pipeline but required by the standard pipeline layout). fn draw_entity( pass: &mut wgpu::RenderPass<'_>, mesh: &Mesh, material: &Material, frame_bind_group: &wgpu::BindGroup, object_bind_group: &wgpu::BindGroup, + shadow_bind_group: &wgpu::BindGroup, ) { if mesh.num_vertices == 0 { // No vertices — nothing to render. @@ -445,6 +687,9 @@ fn draw_entity( // Étape 10 (DRAFT 10.4) : groupe texture — le Material possède son bind group (placeholder // blanc s'il n'a pas de texture, D1/D2). Toujours liable car posé sur toutes les pipelines. pass.set_bind_group(2, &material.texture_bind_group, &[]); + // Étape 14 : groupe ombre — toujours lié pour rester conforme au layout unifié, que la pipeline + // soit éclairée ou non (le groupe @3 reste requis par toutes les pipelines standards). + pass.set_bind_group(3, shadow_bind_group, &[]); pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); if let Some(index_buffer) = &mesh.index_buffer { pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); diff --git a/lib/src/pipeline/mod.rs b/lib/src/pipeline/mod.rs index c05335f..0704388 100644 --- a/lib/src/pipeline/mod.rs +++ b/lib/src/pipeline/mod.rs @@ -12,6 +12,7 @@ pub mod pipeline_cache; // Re-exports pub use pipeline_cache::{ - DEPTH_FORMAT, PipelineCache, create_texture_bind_group_layout, - create_uniform_bind_group_layouts, + DEPTH_FORMAT, PipelineCache, build_shadow_pipeline, create_shadow_map_bind_group_layout, + create_shadow_uniform_layout, create_texture_bind_group_layout, + create_uniform_bind_group_layouts, vertex_buffer_layout, }; diff --git a/lib/src/pipeline/pipeline_cache.rs b/lib/src/pipeline/pipeline_cache.rs index fb06fbe..9c54098 100644 --- a/lib/src/pipeline/pipeline_cache.rs +++ b/lib/src/pipeline/pipeline_cache.rs @@ -90,6 +90,89 @@ pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGrou }) } +/// Creates the **shadow map** bind group layout (group 3) shared by every main pipeline (Étape 14, +/// DRAFT D1/D5). Binds a **comparison** sampler + a depth texture so the fragment can run a PCF +/// `textureSampleCompare` against the shadow map. Added to every pipeline layout alongside groups +/// 0–2, keeping « un seul layout pour tous » — shadows are simply a no-op when disabled. +/// +/// - `binding 0` : `sampler_comparison` (compare fn drives the shadow test, D5). +/// - `binding 1` : `texture_depth_2d` (the shadow map). +pub fn create_shadow_map_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("shadow_map_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }) +} + +/// Creates the **shadow uniform** bind group layout (group 0 of the depth-only shadow pipeline, +/// Étape 14, D4): a single uniform buffer holding the light's `view_proj` matrix. Read in the +/// **vertex** stage only (the shadow shader transforms vertices into light-clip space). +pub fn create_shadow_uniform_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("shadow_uniform_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }) +} + +/// The shared GPU `Vertex`-buffer layout used by **every** pipeline that renders mesh geometry +/// (both the main `build_pipeline` and the depth-only shadow pipeline). The array stride equals +/// `size_of::()` so it matches the mesh vertex buffers exactly; the four attributes are +/// declared position (loc 0), normal (1), uv (2), color (3). +pub fn vertex_buffer_layout() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, // position + wgpu::VertexAttribute { + offset: 12, + shader_location: 1, + format: wgpu::VertexFormat::Float32x3, + }, // normal + wgpu::VertexAttribute { + offset: 24, + shader_location: 2, + format: wgpu::VertexFormat::Float32x2, + }, // uv + wgpu::VertexAttribute { + offset: 32, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, // color + ], + } +} + /// Depth texture format shared by the whole library (Étape 9, décision D1 du 2026-09-18). /// /// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the @@ -253,43 +336,21 @@ impl PipelineCache { ) -> wgpu::RenderPipeline { // Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs. // Must match Vertex struct field offsets exactly. - let vertex_buffer_layout = wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::() as wgpu::BufferAddress, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[ - wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x3, - }, // position - wgpu::VertexAttribute { - offset: 12, - shader_location: 1, - format: wgpu::VertexFormat::Float32x3, - }, // normal - wgpu::VertexAttribute { - offset: 24, - shader_location: 2, - format: wgpu::VertexFormat::Float32x2, - }, // uv - wgpu::VertexAttribute { - offset: 32, - shader_location: 3, - format: wgpu::VertexFormat::Float32x4, - }, // color - ], - }; + let vertex_buffer_layout = vertex_buffer_layout(); - // Pipeline layout — the two uniform bind groups (frame @0 + object @1) AND the texture - // bind group (@2, Étape 10 DRAFT D1) are attached to EVERY pipeline (Étape 3, décision - // actée « un seul layout pour tous »), even if a given shader does not read them. + // Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture + // bind group (@2, Étape 10 DRAFT D1) AND the shadow-map bind group (@3, Étape 14 D5) are + // attached to EVERY pipeline (Étape 3, décision actée « un seul layout pour tous »), even + // if a given shader does not read them. // `immediate_size` stays 0 (no var used). let uniform_layouts = create_uniform_bind_group_layouts(device); let texture_layout = create_texture_bind_group_layout(device); + let shadow_layout = create_shadow_map_bind_group_layout(device); let layout_refs: Vec> = vec![ Some(&uniform_layouts[0]), // frame @0 Some(&uniform_layouts[1]), // object @1 Some(&texture_layout), // texture @2 + Some(&shadow_layout), // shadow map @3 ]; let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -346,3 +407,67 @@ impl PipelineCache { self.pipelines.get(shader_id) } } + +/// Builds the **depth-only shadow pipeline** (Étape 14, D4): a vertex-only pipeline (no fragment +/// stage) that transforms each mesh vertex into the shadow-casting light's clip space, writing only +/// depth. Its layout is [`shadow_uniform_layout`] (group 0 : light `view_proj`) + [`object_layout`] +/// (group 1 : per-entity model matrix — the SAME layout/bind groups the main renderer already caches +/// per entity, so the shadow pass reuses them directly). +/// +/// `depth_stencil` writes depth with a slope-scaled bias (D5) to suppress acne on surfaces nearly +/// parallel to the light. The vertex buffer layout is the shared [`vertex_buffer_layout`], so the +/// same mesh vertex/index buffers are reused. +/// +/// Inputs: device (GPU), object_layout (the shared per-object bind group layout, group 1). +/// Returns the compiled shadow pipeline, ready to render into a depth attachment. +pub fn build_shadow_pipeline( + device: &wgpu::Device, + object_layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline { + // Vertex-only shader : this pipeline sets `fragment: None`, so only the depth is produced. + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("shadow_shader"), + source: wgpu::ShaderSource::Wgsl(crate::utils::SHADOW_SHADER.into()), + }); + + let shadow_uniform_layout = create_shadow_uniform_layout(device); + let layout_refs: Vec> = + vec![Some(&shadow_uniform_layout), Some(object_layout)]; + let shadow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("shadow_pipeline_layout"), + bind_group_layouts: &layout_refs, + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Shadow Pipeline"), + layout: Some(&shadow_pipeline_layout), + // wgpu 30 : vertex state requires `compilation_options`. + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[Some(vertex_buffer_layout())], + }, + // Depth-only : no fragment state (no color output, no color target). + fragment: None, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + // Étape 14 (D5) : slope-scaled depth bias against acne — surfaces nearly parallel to + // the light are pushed back slightly in the shadow map so they do not self-shadow. + bias: wgpu::DepthBiasState { + constant: 2, + slope_scale: 2.0, + clamp: 0.0, + }, + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + diff --git a/lib/src/resources/lights.rs b/lib/src/resources/lights.rs index 717e3cb..3f561a5 100644 --- a/lib/src/resources/lights.rs +++ b/lib/src/resources/lights.rs @@ -58,6 +58,22 @@ impl Lights { self.len() == 0 } + /// Returns the light at a **packed-array index** (directionals first, then point lights, then + /// spot lights — the same order as `into_frame_array`). Used by the Renderer's shadow pass to + /// resolve the shadow-casting light by its packed index (`Scene::shadow_caster`, Étape 14 D7). + pub fn get(&self, index: usize) -> Option<&Light> { + let n_dir = self.directional.len(); + if index < n_dir { + return self.directional.get(index); + } + let index = index - n_dir; + let n_point = self.point.len(); + if index < n_point { + return self.point.get(index); + } + self.spot.get(index - n_point) + } + /// 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` diff --git a/lib/src/resources/mod.rs b/lib/src/resources/mod.rs index a225600..ee2902d 100644 --- a/lib/src/resources/mod.rs +++ b/lib/src/resources/mod.rs @@ -26,7 +26,10 @@ pub use lights::Lights; pub use material::Material; pub use mesh::Mesh; pub use texture::{Texture, TextureError}; -pub use uniform::{FrameUniforms, Light, MAX_LIGHTS, ObjectUniform}; +pub use uniform::{ + FrameUniforms, Light, LightType, MAX_LIGHTS, ObjectUniform, ShadowUniform, FRAME_UNIFORMS_SIZE, + OBJECT_UNIFORM_SIZE, SHADOW_UNIFORM_SIZE, +}; pub use vertex::Vertex; // Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 913446d..a738fb2 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -5,7 +5,7 @@ //! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding. //! //! Two bind groups are shared by every pipeline (single-layout decision, Étape 3) : -//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights) → 704 bytes +//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights + shadow) → 784 bytes //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) → 64 bytes //! //! ## Interaction with Other Modules @@ -20,6 +20,8 @@ use glam::{Mat4, Vec4}; pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::() as u64; /// Byte size of the per-object uniform buffer (`ObjectUniform`). pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::() as u64; +/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Étape 14). +pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::() as u64; /// Maximum number of lights stored in the per-frame uniform buffer. /// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation). @@ -52,12 +54,47 @@ pub struct Light { pub dir_angle: Vec4, } -/// Per-frame GPU uniforms : camera matrices + ambient + global light list + options. +/// The runtime-disambiguated type of a [`Light`] (Étape 14, D6). Not stored in the struct (the array +/// position disambiguates on the GPU); used by CPU-side logic such as the shadow-pass light selection, +/// which must reject point lights (cubemap shadows are out of scope). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LightType { + /// Directional light (infinitely distant): `position_dir.xyz` = ray direction away from the + /// light, `radius.x` = 0, `dir_angle` = 0. + Directional, + /// Point (omnidirectional): `position_dir.xyz` = world position, `radius.x` = attenuation + /// radius, `dir_angle` = 0. + Point, + /// Spot: world position in `position_dir.xyz`, `radius.x` = attenuation radius, cone axis in + /// `dir_angle.xyz` and `dir_angle.w` = cos of the half-angle. + Spot, +} + +impl Light { + /// Classifies the light for CPU-side logic. Query order is significant because a spot light + /// carries both a positive attenuation radius **and** a positive `dir_angle.w` (cos of a + /// sub-90° half-angle), so the cone flag is tested first, then the radius, and anything else is + /// the infinite directional light. Returns [`LightType::Directional`], [`LightType::Point`] or + /// [`LightType::Spot`]. + pub fn light_type(&self) -> LightType { + if self.dir_angle.w > 0.0 { + LightType::Spot + } else if self.radius.x > 0.0 { + LightType::Point + } else { + LightType::Directional + } + } +} + +/// Per-frame GPU uniforms : camera matrices + ambient + global light list + shadow data + options. /// /// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there). -/// 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). +/// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow +/// light selection, the light view-projection matrix + shadow parameters, then options — total +/// **784 bytes** (Étape 14, DRAFT 3.1), 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 { @@ -78,11 +115,18 @@ pub struct FrameUniforms { pub num_point: u32, /// Number of active spot lights (indices after the point lights). pub num_spot: u32, - /// Padding so `options` lands on a 16-byte boundary — matching the WGSL `vec4` - /// (alignment 16), which Rust's `repr(C)` would otherwise place 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). + /// Index (in the packed frame array) of the single shadow-casting light (DRAFT Étape 14, D1). + /// `MAX_LIGHTS` = sentinel meaning "no shadow" (shadows off). Offset 160 + 64·MAX_LIGHTS + 12. + pub shadow_light_index: u32, + /// View-projection matrix of the shadow-casting light (world → light clip space), used to + /// reproject fragments into the shadow map (DRAFT Étape 14, D3). Offset 176 + 64·MAX_LIGHTS. + pub light_view_proj: Mat4, + /// Shadow sampling parameters (DRAFT Étape 14, D5). `x` = shadow map size in pixels (for + /// texel-space PCF offsets), `y` = depth bias, `z`/`w` reserved. Offset 240 + 64·MAX_LIGHTS. + pub shadow_params: Vec4, + /// Options. `options[0]` = unlit flag (1 → flat color, no lighting); + /// `options[1]` = shadows enabled (1 → sample the shadow map, checked alongside + /// `shadow_light_index`). Offset 256 + 64·MAX_LIGHTS. pub options: [u32; 4], } @@ -105,7 +149,10 @@ impl Default for FrameUniforms { num_directional: 1, num_point: 0, num_spot: 0, - _pad: [0], + // Shadows off by default (Étape 14, D7 — non-régression) : sentinel = MAX_LIGHTS. + shadow_light_index: MAX_LIGHTS as u32, + light_view_proj: Mat4::IDENTITY, + shadow_params: Vec4::ZERO, options: [0, 0, 0, 0], } } @@ -121,6 +168,16 @@ pub struct ObjectUniform { pub model: Mat4, } +/// GPU uniforms of the depth-only shadow pass (Étape 14, D4): the shadow-casting light's +/// view-projection matrix. Mirrors the WGSL `ShadowUniform` struct in `shadow_shader.wgsl`. +/// 64 bytes, `Pod`, bound as group 0 of the shadow pipeline. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable, Default)] +pub struct ShadowUniform { + /// Light view-projection matrix (world → light clip space). Offset 0. + pub view_proj: Mat4, +} + #[cfg(test)] mod tests { use super::*; @@ -130,9 +187,11 @@ 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 = 64·MAX_LIGHTS, then counters(12) + pad(4) + - // options(16) = 32. Total = 160 + 64·MAX_LIGHTS + 32 = 704 bytes. - assert_eq!(size_of::(), 160 + 64 * MAX_LIGHTS + 32); + // Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16), + // light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters. + // Total = 160 + 64·8 + 16 + 112 = 784 bytes. + assert_eq!(size_of::(), 784); + assert_eq!(size_of::(), 160 + 512 + 112); assert_eq!(align_of::(), 16); let f = FrameUniforms::default(); @@ -145,15 +204,31 @@ mod tests { offset_of!(FrameUniforms, num_directional), 160 + 64 * MAX_LIGHTS ); + assert_eq!(offset_of!(FrameUniforms, num_point), 160 + 64 * MAX_LIGHTS + 4); + assert_eq!(offset_of!(FrameUniforms, num_spot), 160 + 64 * MAX_LIGHTS + 8); assert_eq!( - offset_of!(FrameUniforms, options), + offset_of!(FrameUniforms, shadow_light_index), + 160 + 64 * MAX_LIGHTS + 12 + ); + assert_eq!( + offset_of!(FrameUniforms, light_view_proj), 160 + 64 * MAX_LIGHTS + 16 ); - // Default is lit mode (unlit flag cleared), one directional light, no point/spot lights. + assert_eq!( + offset_of!(FrameUniforms, shadow_params), + 160 + 64 * MAX_LIGHTS + 80 + ); + assert_eq!( + offset_of!(FrameUniforms, options), + 160 + 64 * MAX_LIGHTS + 96 + ); + // Default is lit mode (unlit flag cleared), one directional light, no point/spot lights, + // shadows off (sentinel = MAX_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); + assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32); } #[test] diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index a47f882..464730f 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -64,6 +64,11 @@ pub struct Scene { lights: Lights, /// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white. ambient: [f32; 3], + /// Optional shadow-casting light index (DRAFT Étape 14, D1): the index (in the packed frame + /// array: directionals, then points, then spots) of the single light that casts a shadow. + /// `None` = shadows off (default, non-régression). Read each frame by `Renderer::render_scene` + /// to compute the light `view_proj` and enable shadow sampling. + shadow_caster: Option, } impl Scene { @@ -82,6 +87,7 @@ impl Scene { default_material: RefCell::new(None), lights: Lights::new(), ambient: [1.0, 1.0, 1.0], + shadow_caster: None, } } @@ -343,6 +349,22 @@ impl Scene { &self.lights } + /// Selects the single shadow-casting light by **its index in the packed frame array** + /// (directionals first, then point lights, then spots — same order as + /// `Lights::into_frame_array`). `None` disables shadows (default, non-régression, Étape 14 D7). + /// The light must be **directional or spot**; a point light index disables the shadow pass + /// (cubemap shadows are out of scope, D6). Inputs: index — the light's packed-array index, or + /// `None` to turn shadows off. + pub fn set_shadow_caster(&mut self, index: Option) { + self.shadow_caster = index; + } + + /// Returns the index of the scene's shadow-casting light (`None` = shadows off). + /// Read by `Renderer::render_scene` each frame to decide whether to run the shadow pass. + pub fn shadow_caster(&self) -> Option { + self.shadow_caster + } + /// 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) { diff --git a/lib/src/shaders/shadow_shader.wgsl b/lib/src/shaders/shadow_shader.wgsl new file mode 100644 index 0000000..d9c5b01 --- /dev/null +++ b/lib/src/shaders/shadow_shader.wgsl @@ -0,0 +1,48 @@ +//! # Shadow Shader (Étape 14, Phase 4.2 — depth-only pass) +//! +//! Minimal vertex shader used for the **shadow map pass** (DRAFT Étape 14, D4). It transforms each +//! vertex into the light's clip space and lets the depth write happen — no fragment stage, no color +//! output, no lighting : the rasterizer only records the depth (D2). +//! +//! Only the `position` attribute (location 0) is consumed, so this pipeline needs no normal/uv/color +//! buffers and is as cheap as possible. +//! +//! ## Uniform Contract (this pipeline's own layout — independent of the main pipeline) +//! - `@group(0) @binding(0)` : `ShadowUniform` — the light's `view_proj` matrix (world → light clip). +//! - `@group(1) @binding(0)` : `ObjectUniform` — the entity's per-entity model matrix (shared with +//! the main pipeline, so the Renderer reuses its per-entity object bind groups). +//! +//! The light VP is passed as a group-0 uniform rather than reusing the camera `FrameUniforms` +//! because the shadow pass is rendered from the light's point of view, not the camera's. + +struct ShadowUniform { + view_proj: mat4x4, +}; + +struct ObjectUniform { + model: mat4x4, +}; + +@group(0) @binding(0) var shadow: ShadowUniform; +@group(1) @binding(0) var object: ObjectUniform; + +struct VertexInput { + @location(0) position: vec3, + @location(1) normal: vec3, + @location(2) uv: vec2, + @location(3) color: vec4, +}; + +// Output carries only the clip position; any attribute interpolated without a fragment stage is +// still fine (it is simply discarded). Keeping just the position minimizes the vertex output size. +struct VertexOutput { + @builtin(position) clip_position: vec4, +}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var out: VertexOutput; + let world = object.model * vec4(input.position, 1.0); + out.clip_position = shadow.view_proj * world; + return out; +} diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index e7a12c5..e251096 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -7,24 +7,29 @@ //! modulates the vertex color (`texel.rgb * in.color.rgb`). //! //! ## 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) [704 bytes] +//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) : +//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow) [784 bytes] //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes] //! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10) //! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d) (Étape 10) +//! - `@group(3) @binding(0)` : `shadow_sampler` (sampler_comparison) (Étape 14) +//! - `@group(3) @binding(1)` : `shadow_map` (texture_depth_2d) (Étape 14) //! //! `FrameUniforms` layout (std140 — each element 16-byte aligned) : -//! | Offset | Field | Type | Meaning | -//! |-----------------------|----------------|---------------|----------------------------------| -//! | 0 | view | mat4x4 | Camera view matrix | -//! | 64 | proj | mat4x4 | Camera projection matrix | -//! | 128 | cam_pos | vec4 | Camera world position (.xyz) | -//! | 144 | ambient | vec4 | Ambient hemisphere color (.rgb) | -//! | 160 | lights[0..MAX] | array | Global light list | -//! | 160 + 64·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) | -//! | | num_point | u32 | # point (indices n..) | -//! | | num_spot | u32 | # spot (indices after point) | -//! | | options | vec4 | x = unlit flag (1 => flat color) | +//! | Offset | Field | Type | Meaning | +//! |-----------------------|-------------------|---------------|----------------------------------| +//! | 0 | view | mat4x4 | Camera view matrix | +//! | 64 | proj | mat4x4 | Camera projection matrix | +//! | 128 | cam_pos | vec4 | Camera world position (.xyz) | +//! | 144 | ambient | vec4 | Ambient hemisphere color (.rgb) | +//! | 160 | lights[0..MAX] | array | Global light list | +//! | 160 + 64·MAX_LIGHTS | num_directional | u32 | # directional (indices 0..n) | +//! | | num_point | u32 | # point (indices n..) | +//! | | num_spot | u32 | # spot (indices after point) | +//! | | shadow_light_index| u32 | packed index of shadow light | +//! | 160 + 64·MAX_LIGHTS+16| light_view_proj | mat4x4 | world → light clip space (D3) | +//! | | shadow_params | vec4 | .x = map size, .y = depth bias | +//! | | options | vec4 | .x = unlit ; .y = shadows on | //! //! `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 @@ -79,12 +84,15 @@ struct FrameUniforms { view: mat4x4, proj: mat4x4, cam_pos: vec4, - ambient: vec4, // .rgb = ambient hemisphere color - lights: array, // directional, then point, then spot + ambient: vec4, // .rgb = ambient hemisphere color + lights: array, // directional, then point, then spot num_directional: u32, num_point: u32, num_spot: u32, - options: vec4, // .x : unlit flag (1 = flat color, no lighting) + shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off + light_view_proj: mat4x4, // world → shadow light clip space (Étape 14, D3) + shadow_params: vec4, // .x = shadow map size, .y = depth bias + options: vec4, // .x = unlit flag ; .y = shadows on }; struct ObjectUniform { @@ -97,6 +105,10 @@ struct ObjectUniform { // texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel. @group(2) @binding(0) var texture_sampler: sampler; @group(2) @binding(1) var diffuse_texture: texture_2d; +// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1). +// Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées). +@group(3) @binding(0) var shadow_sampler: sampler_comparison; +@group(3) @binding(1) var shadow_map: texture_depth_2d; struct VertexOutput { @builtin(position) clip_position: vec4, @@ -189,6 +201,37 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor; } - let lit = base * (ambient + diffuse); + let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos); return vec4(lit, in.color.a); } + +// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position +// into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages +// a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (GreaterEqual). Returns +// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow. The reference depth is +// pulled toward the viewer by `frame.shadow_params.y` (bias) to suppress acne. +fn compute_shadow(world_pos: vec3) -> f32 { + // Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit. + if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) { + return 1.0; + } + let light_clip = frame.light_view_proj * vec4(world_pos, 1.0); + // Perspective divide then map NDC [-1,1] → UV [0,1]. Orthographic depth is linear in the map. + let shadow_ndc = light_clip.xyz / max(light_clip.w, 1e-6); + var shadow_uv = shadow_ndc.xy * 0.5 + 0.5; + shadow_uv = vec2(shadow_uv.x, 1.0 - shadow_uv.y); // flip V for texture coordinates + let current_depth = shadow_ndc.z * 0.5 + 0.5; + let bias = frame.shadow_params.y; + let texel = 1.0 / max(frame.shadow_params.x, 1.0); + + // 3×3 PCF : average of the comparison results around the fragment's texel. + var lit_count = 0.0; + for (var ox = -1i; ox <= 1; ox++) { + for (var oy = -1i; oy <= 1; oy++) { + let offset = vec2(f32(ox), f32(oy)) * texel; + lit_count += textureSampleCompare( + shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias); + } + } + return lit_count / 9.0; +} diff --git a/lib/src/utils/conf.rs b/lib/src/utils/conf.rs index c5b6cb4..04447c1 100644 --- a/lib/src/utils/conf.rs +++ b/lib/src/utils/conf.rs @@ -21,6 +21,35 @@ pub const STANDARD_SHADER_PATH: &str = "assets/shaders/standard_shader.wgsl"; /// pipeline uses the unified layout (frame @0 + object @1), this is the only shader the library ships. pub const STANDARD_SHADER: &str = include_str!("../shaders/standard_shader.wgsl"); +/// Path to the depth-only **shadow** WGSL shader on disk (Étape 14, D4). Used by the Renderer's +/// shadow-map pass: a minimal vertex shader that transforms vertices into light-clip space. +pub const SHADOW_SHADER_PATH: &str = "assets/shaders/shadow_shader.wgsl"; + +/// The depth-only shadow WGSL shader source, embedded at compile time via `include_str!` +/// (Étape 14, D4). Serves as the fallback when `SHADOW_SHADER_PATH` cannot be read. +pub const SHADOW_SHADER: &str = include_str!("../shaders/shadow_shader.wgsl"); + +/// Default shadow-map resolution in pixels per side (square, D2). A 1024² depth map is a good +/// quality/cost trade-off for the dedicated `shadow_test` example and most simple scenes. +pub const SHADOW_MAP_SIZE: u32 = 1024; + +/// Default shadow depth bias (Étape 14, D5) subtracted from the reference depth before the +/// comparison, to suppress acne without killing contact shadows. Combined with the slope-scaled +/// bias applied on the shadow pipeline itself. +pub const SHADOW_DEPTH_BIAS: f32 = 0.006; + +/// Default half-extent (world units) of the orthographic shadow frustum around the scene center +/// for a directional light (D3). Chosen to comfortably frame the unit-cube scene of the examples. +pub const SHADOW_SCENE_RADIUS: f32 = 5.0; + +/// Default world-space scene center used to place the shadow light for the examples (D3). +pub const SHADOW_SCENE_CENTER: [f32; 3] = [0.0, 0.0, 0.0]; + +/// Maximum number of lights in the packed frame light array (re-exported from the uniform layout +/// so upper layers can address the shadow light safely, Étape 14 D7). Also used as the no-caster +/// sentinel for `FrameUniforms.shadow_light_index`. +pub use crate::resources::uniform::MAX_LIGHTS; + /// Default application title displayed in the OS taskbar/window decorations. pub const APP_DEFAULT_TITLE: &str = "WSG App"; diff --git a/lib/src/utils/mod.rs b/lib/src/utils/mod.rs index 19606e1..92be4eb 100644 --- a/lib/src/utils/mod.rs +++ b/lib/src/utils/mod.rs @@ -13,6 +13,8 @@ pub mod conf; pub mod error; // Re-exports -pub use conf::STANDARD_SHADER; -pub use conf::STANDARD_SHADER_PATH; +pub use conf::{ + SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, SHADOW_SHADER, SHADOW_SHADER_PATH, + STANDARD_SHADER, STANDARD_SHADER_PATH, +}; pub use error::WsgError; diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index 7dfdafb..03f0557 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -28,3 +28,29 @@ fn standard_shader_is_valid_wgsl() { // Contrat : exactement les deux entrées vs_main / fs_main attendues. assert!(module.entry_points.len() >= 2, "vs_main + fs_main attendus"); } + +/// Parse et valide complètement le shader embarqué `shadow_shader.wgsl` (Étape 14, D4) via naga. +/// Le pipeline « shadow » est câblé directement par `build_shadow_pipeline` (sans passer par le +/// PipelineCache), donc cette validation hors-ligne est la garantie de sa validité. Le contrat +/// n'attend qu'une seule entrée (`vs_main` — pipeline sans fragment stage). +#[test] +fn shadow_shader_is_valid_wgsl() { + let src = include_str!("../src/shaders/shadow_shader.wgsl"); + let module = naga::front::wgsl::parse_str(src) + .unwrap_or_else(|e| panic!("shadow_shader.wgsl : erreur de parsing : {e:?}")); + + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .unwrap_or_else(|e| panic!("shadow_shader.wgsl : échec de validation : {e:?}")); + + let entry_names: Vec<&str> = module + .entry_points + .iter() + .map(|ep| ep.name.as_str()) + .collect(); + assert_eq!(entry_names, vec!["vs_main"], "seule vs_main attendue"); +}