Étape 14: add shadow mapping (directional light, Phase 4.2)

Implement shadow mapping for directional lights:
- Scene::set_shadow_caster(Option<usize>) selects the shadow-casting light
  by packed frame-array index (None disables; point lights rejected at render).
- Lights::get(index) resolves a packed index across the directional/point/spot lists.
- Renderer allocates a shadow depth map, comparison sampler, group-3 bind groups,
  shadow uniform buffer and shadow pipeline; render_scene does a depth-only
  shadow pass before the main pass; compute_shadow_light_view_proj builds an
  orthographic light-space frustum from the scene radius.
- standard_shader: shadow_light_index/light_view_proj/shadow_params uniforms,
  @group(3) depth map + comparison sampler, 3x3 PCF compute_shadow().
- shadow_shader: path/vertex shader with attribute layout matching the shared
  vertex buffer (only position consumed).
- shadow_test example: directional shadow caster casts a PCF-softened shadow
  onto a ground slab; documented in examples README.
This commit is contained in:
Jérôme Bousquié
2026-09-19 09:48:17 +02:00
parent 8779af067f
commit c2cbd7fadb
14 changed files with 850 additions and 82 deletions
+1
View File
@@ -13,6 +13,7 @@ cargo run -p wsg-lib --example <name>
| `cube` | `cargo run -p wsg-lib --example cube` | Textured cube (procedural checker) lit by a directional + point + spot light. | | `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). | | `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. | | `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 ## Conventions
+132
View File
@@ -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)
}
+260 -15
View File
@@ -21,11 +21,19 @@
use crate::core::Context; use crate::core::Context;
use crate::core::Frame; use crate::core::Frame;
use crate::math::Transform; use crate::math::Transform;
use crate::pipeline::{DEPTH_FORMAT, create_uniform_bind_group_layouts}; use crate::pipeline::{
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE}; DEPTH_FORMAT, build_shadow_pipeline, create_shadow_map_bind_group_layout,
use crate::resources::{Camera, FrameUniforms, Lights, Material, Mesh, ObjectUniform}; 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 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::cell::RefCell;
use std::collections::HashMap; 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 /// `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). /// rendering is thus a special case of the 3D lit path (DRAFT Étape 5). Defaults to `false` (lit).
unlit: bool, 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 { 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 { let renderer = Self {
queue, queue,
device, device,
@@ -137,6 +210,12 @@ impl Renderer {
shared_object_bind_group, shared_object_bind_group,
object_cache: RefCell::new(HashMap::new()), object_cache: RefCell::new(HashMap::new()),
unlit: false, 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 // 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. // `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) { fn write_default_frame_uniforms(&self) {
let frame = FrameUniforms { let frame = FrameUniforms {
options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], 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() ..FrameUniforms::default()
}; };
self.queue self.queue
@@ -192,15 +274,28 @@ impl Renderer {
/// The light array is packed via `Lights::into_frame_array` (directionals first, then point, /// 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 /// 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 / /// 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( fn write_frame_uniforms(
&self, &self,
camera: &Camera, camera: &Camera,
lights: &Lights, lights: &Lights,
ambient: [f32; 3], ambient: [f32; 3],
aspect: f32, aspect: f32,
shadow_caster: Option<usize>,
) { ) {
let (light_array, num_directional, num_point, num_spot) = lights.into_frame_array(); 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 { let frame = FrameUniforms {
view: camera.view_matrix(), view: camera.view_matrix(),
proj: camera.projection_matrix(aspect), proj: camera.projection_matrix(aspect),
@@ -210,13 +305,60 @@ impl Renderer {
num_directional, num_directional,
num_point, num_point,
num_spot, num_spot,
_pad: [0], shadow_light_index,
options: [if self.unlit { 1 } else { 0 }, 0, 0, 0], light_view_proj,
shadow_params,
options: [if self.unlit { 1 } else { 0 }, shadow_on, 0, 0],
}; };
self.queue self.queue
.write_buffer(&self.frame_buffer, 0, bytemuck::bytes_of(&frame)); .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<usize>,
) -> 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, /// 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). /// 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). /// Inputs: view (TextureView color attachment target), mesh (geometry to render), material (shader+pipeline).
@@ -265,6 +407,7 @@ impl Renderer {
material, material,
&self.frame_bind_group, &self.frame_bind_group,
&self.shared_object_bind_group, &self.shared_object_bind_group,
&self.shadow_bind_group,
); );
} }
self.queue.submit(std::iter::once(encoder.finish())); 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 /// Before drawing, the shared frame uniform buffer is rewritten from `scene.camera()` so the GPU
/// receives the active camera's view/projection matrices and position for this frame (Étape 4.3). /// receives the active camera's view/projection matrices and position for this frame (Étape 4.3).
pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) { pub fn render_scene(&self, view: &wgpu::TextureView, scene: &Scene, aspect: f32) {
self.write_frame_uniforms(scene.camera(), scene.lights(), scene.ambient(), aspect); self.write_frame_uniforms(
scene.camera(),
scene.lights(),
scene.ambient(),
aspect,
scene.shadow_caster(),
);
let mut encoder = self let mut encoder = self
.device .device
@@ -289,6 +438,11 @@ impl Renderer {
label: Some("scene encoder"), 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 { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene render pass"), label: Some("scene render pass"),
@@ -328,12 +482,69 @@ impl Renderer {
&material, &material,
&self.frame_bind_group, &self.frame_bind_group,
&object_bind_group, &object_bind_group,
&self.shadow_bind_group,
); );
} }
} }
self.queue.submit(std::iter::once(encoder.finish())); 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. /// 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() /// 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(). /// 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) (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`. /// 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 /// The frame (@0), object (@1), texture (@2) and shadow-map (@3) bind groups are **required** by
/// (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture) — they must be bound even if the /// every pipeline layout (Étape 3 : un seul layout pour tous — Étape 10 : groupe texture — Étape 14 :
/// shader does not read them. Draws indexed geometry when an index buffer exists, otherwise falls /// groupe ombre) — they must be bound even if the shader does not read them. Draws indexed geometry
/// back to a non-indexed draw. /// 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 /// 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 /// group to bind), frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/
/// model). /// 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( fn draw_entity(
pass: &mut wgpu::RenderPass<'_>, pass: &mut wgpu::RenderPass<'_>,
mesh: &Mesh, mesh: &Mesh,
material: &Material, material: &Material,
frame_bind_group: &wgpu::BindGroup, frame_bind_group: &wgpu::BindGroup,
object_bind_group: &wgpu::BindGroup, object_bind_group: &wgpu::BindGroup,
shadow_bind_group: &wgpu::BindGroup,
) { ) {
if mesh.num_vertices == 0 { if mesh.num_vertices == 0 {
// No vertices — nothing to render. // 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 // É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. // 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, &[]); 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(..)); pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer { if let Some(index_buffer) = &mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
+3 -2
View File
@@ -12,6 +12,7 @@
pub mod pipeline_cache; pub mod pipeline_cache;
// Re-exports // Re-exports
pub use pipeline_cache::{ pub use pipeline_cache::{
DEPTH_FORMAT, PipelineCache, create_texture_bind_group_layout, DEPTH_FORMAT, PipelineCache, build_shadow_pipeline, create_shadow_map_bind_group_layout,
create_uniform_bind_group_layouts, create_shadow_uniform_layout, create_texture_bind_group_layout,
create_uniform_bind_group_layouts, vertex_buffer_layout,
}; };
+154 -29
View File
@@ -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::<Vertex>()` 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::<Vertex>() 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). /// 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 /// Single z-buffer format used for **both** the depth attachment textures (`Renderer`) and the
@@ -253,43 +336,21 @@ impl PipelineCache {
) -> wgpu::RenderPipeline { ) -> wgpu::RenderPipeline {
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs. // Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
// Must match Vertex struct field offsets exactly. // Must match Vertex struct field offsets exactly.
let vertex_buffer_layout = wgpu::VertexBufferLayout { let vertex_buffer_layout = vertex_buffer_layout();
array_stride: std::mem::size_of::<Vertex>() 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
],
};
// Pipeline layout — the two uniform bind groups (frame @0 + object @1) AND the texture // Pipeline layout — the two uniform bind groups (frame @0 + object @1), the texture
// bind group (@2, Étape 10 DRAFT D1) are attached to EVERY pipeline (Étape 3, décision // bind group (@2, Étape 10 DRAFT D1) AND the shadow-map bind group (@3, Étape 14 D5) are
// actée « un seul layout pour tous »), even if a given shader does not read them. // 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<immediate> used). // `immediate_size` stays 0 (no var<immediate> used).
let uniform_layouts = create_uniform_bind_group_layouts(device); let uniform_layouts = create_uniform_bind_group_layouts(device);
let texture_layout = create_texture_bind_group_layout(device); let texture_layout = create_texture_bind_group_layout(device);
let shadow_layout = create_shadow_map_bind_group_layout(device);
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> = vec![ let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> = vec![
Some(&uniform_layouts[0]), // frame @0 Some(&uniform_layouts[0]), // frame @0
Some(&uniform_layouts[1]), // object @1 Some(&uniform_layouts[1]), // object @1
Some(&texture_layout), // texture @2 Some(&texture_layout), // texture @2
Some(&shadow_layout), // shadow map @3
]; ];
let render_pipeline_layout = let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -346,3 +407,67 @@ impl PipelineCache {
self.pipelines.get(shader_id) 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<Option<&wgpu::BindGroupLayout>> =
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,
})
}
+16
View File
@@ -58,6 +58,22 @@ impl Lights {
self.len() == 0 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 /// 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 /// point lights, then spot lights. The tail is zero-filled. Returns
/// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS` /// `(array, num_directional, num_point, num_spot)`. Caller must ensure `len() <= MAX_LIGHTS`
+4 -1
View File
@@ -26,7 +26,10 @@ pub use lights::Lights;
pub use material::Material; pub use material::Material;
pub use mesh::Mesh; pub use mesh::Mesh;
pub use texture::{Texture, TextureError}; pub use texture::{Texture, TextureError};
pub use uniform::{FrameUniforms, 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; pub use vertex::Vertex;
// Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes // Convenience re-export of `math::Geometry` (Étape 8, D2) so examples can build meshes
+91 -16
View File
@@ -5,7 +5,7 @@
//! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding. //! (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) : //! 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 //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) → 64 bytes
//! //!
//! ## Interaction with Other Modules //! ## Interaction with Other Modules
@@ -20,6 +20,8 @@ use glam::{Mat4, Vec4};
pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64; pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64;
/// Byte size of the per-object uniform buffer (`ObjectUniform`). /// Byte size of the per-object uniform buffer (`ObjectUniform`).
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64; pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64;
/// Byte size of the shadow-pass uniform buffer (`ShadowUniform`, Étape 14).
pub const SHADOW_UNIFORM_SIZE: u64 = std::mem::size_of::<ShadowUniform>() as u64;
/// Maximum number of lights stored in the per-frame uniform buffer. /// Maximum number of lights stored in the per-frame uniform buffer.
/// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation). /// Bounded capacity: adding more than this returns `WsgError` (no dynamic UBO allocation).
@@ -52,12 +54,47 @@ pub struct Light {
pub dir_angle: Vec4, 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). /// 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 — /// 160 + 64·MAX_LIGHTS bytes for the camera header + lights, then the counters, the single shadow
/// total **704 bytes**, 16-byte aligned, `Pod` for direct `bytes_of` upload. The bind-group layout /// light selection, the light view-projection matrix + shadow parameters, then options — total
/// uses `min_binding_size: None`, so extending this struct is transparent (no relayout). /// **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)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)] #[derive(Clone, Copy, Pod, Zeroable)]
pub struct FrameUniforms { pub struct FrameUniforms {
@@ -78,11 +115,18 @@ pub struct FrameUniforms {
pub num_point: u32, pub num_point: u32,
/// Number of active spot lights (indices after the point lights). /// Number of active spot lights (indices after the point lights).
pub num_spot: u32, pub num_spot: u32,
/// Padding so `options` lands on a 16-byte boundary — matching the WGSL `vec4<u32>` /// Index (in the packed frame array) of the single shadow-casting light (DRAFT Étape 14, D1).
/// (alignment 16), which Rust's `repr(C)` would otherwise place too early: three u32 counters /// `MAX_LIGHTS` = sentinel meaning "no shadow" (shadows off). Offset 160 + 64·MAX_LIGHTS + 12.
/// occupy 12 bytes, so 4 bytes of padding align `options` to 16. pub shadow_light_index: u32,
pub _pad: [u32; 1], /// View-projection matrix of the shadow-casting light (world → light clip space), used to
/// Options. `options[0]` = unlit flag (1 → flat color, no lighting). /// 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], pub options: [u32; 4],
} }
@@ -105,7 +149,10 @@ impl Default for FrameUniforms {
num_directional: 1, num_directional: 1,
num_point: 0, num_point: 0,
num_spot: 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], options: [0, 0, 0, 0],
} }
} }
@@ -121,6 +168,16 @@ pub struct ObjectUniform {
pub model: Mat4, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -130,9 +187,11 @@ mod tests {
#[test] #[test]
fn frame_uniforms_layout_matches_wgsl() { fn frame_uniforms_layout_matches_wgsl() {
// The offsets below must match the offset table in standard_shader.wgsl. // The offsets below must match the offset table in standard_shader.wgsl.
// Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters(12) + pad(4) + // Header (view..ambient) = 160, lights = 64·MAX_LIGHTS, then counters (4×u32 = 16),
// options(16) = 32. Total = 160 + 64·MAX_LIGHTS + 32 = 704 bytes. // light_view_proj (64) + shadow_params (16) + options (16) = 112 after the counters.
assert_eq!(size_of::<FrameUniforms>(), 160 + 64 * MAX_LIGHTS + 32); // Total = 160 + 64·8 + 16 + 112 = 784 bytes.
assert_eq!(size_of::<FrameUniforms>(), 784);
assert_eq!(size_of::<FrameUniforms>(), 160 + 512 + 112);
assert_eq!(align_of::<FrameUniforms>(), 16); assert_eq!(align_of::<FrameUniforms>(), 16);
let f = FrameUniforms::default(); let f = FrameUniforms::default();
@@ -145,15 +204,31 @@ mod tests {
offset_of!(FrameUniforms, num_directional), offset_of!(FrameUniforms, num_directional),
160 + 64 * MAX_LIGHTS 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!( 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 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.options[0], 0);
assert_eq!(f.num_directional, 1); assert_eq!(f.num_directional, 1);
assert_eq!(f.num_point, 0); assert_eq!(f.num_point, 0);
assert_eq!(f.num_spot, 0); assert_eq!(f.num_spot, 0);
assert_eq!(f.shadow_light_index, MAX_LIGHTS as u32);
} }
#[test] #[test]
+22
View File
@@ -64,6 +64,11 @@ pub struct Scene {
lights: Lights, lights: Lights,
/// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white. /// Ambient hemisphere color (rgb) used by the `standard` shader. Default = white.
ambient: [f32; 3], 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<usize>,
} }
impl Scene { impl Scene {
@@ -82,6 +87,7 @@ impl Scene {
default_material: RefCell::new(None), default_material: RefCell::new(None),
lights: Lights::new(), lights: Lights::new(),
ambient: [1.0, 1.0, 1.0], ambient: [1.0, 1.0, 1.0],
shadow_caster: None,
} }
} }
@@ -343,6 +349,22 @@ impl Scene {
&self.lights &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<usize>) {
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<usize> {
self.shadow_caster
}
/// Removes all lights (directional, point and spot). 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`. /// only the ambient term. Useful for flat look without toggling `unlit`.
pub fn clear_lights(&mut self) { pub fn clear_lights(&mut self) {
+48
View File
@@ -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<f32>,
};
struct ObjectUniform {
model: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> shadow: ShadowUniform;
@group(1) @binding(0) var<uniform> object: ObjectUniform;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
// 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<f32>,
};
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world = object.model * vec4<f32>(input.position, 1.0);
out.clip_position = shadow.view_proj * world;
return out;
}
+50 -7
View File
@@ -7,24 +7,29 @@
//! modulates the vertex color (`texel.rgb * in.color.rgb`). //! modulates the vertex color (`texel.rgb * in.color.rgb`).
//! //!
//! ## Uniform Contract //! ## Uniform Contract
//! Three bind groups, shared by every material (one single pipeline layout — voir Étape 3) : //! Four bind groups, shared by every material (one single pipeline layout — voir É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] //! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10) //! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10) //! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
//! - `@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) : //! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
//! | Offset | Field | Type | Meaning | //! | Offset | Field | Type | Meaning |
//! |-----------------------|----------------|---------------|----------------------------------| //! |-----------------------|-------------------|---------------|----------------------------------|
//! | 0 | view | mat4x4<f32> | Camera view matrix | //! | 0 | view | mat4x4<f32> | Camera view matrix |
//! | 64 | proj | mat4x4<f32> | Camera projection matrix | //! | 64 | proj | mat4x4<f32> | Camera projection matrix |
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) | //! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) | //! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
//! | 160 | lights[0..MAX] | array<Light> | Global light list | //! | 160 | lights[0..MAX] | array<Light> | Global light list |
//! | 160 + 64·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_point | u32 | # point (indices n..) |
//! | | num_spot | u32 | # spot (indices after point) | //! | | num_spot | u32 | # spot (indices after point) |
//! | | options | vec4<u32> | x = unlit flag (1 => flat color) | //! | | shadow_light_index| u32 | packed index of shadow light |
//! | 160 + 64·MAX_LIGHTS+16| light_view_proj | mat4x4<f32> | world → light clip space (D3) |
//! | | shadow_params | vec4<f32> | .x = map size, .y = depth bias |
//! | | options | vec4<u32> | .x = unlit ; .y = shadows on |
//! //!
//! `MAX_LIGHTS = 8`. `struct Light` is 64 bytes (4 × 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 //! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the
@@ -84,7 +89,10 @@ struct FrameUniforms {
num_directional: u32, num_directional: u32,
num_point: u32, num_point: u32,
num_spot: u32, num_spot: u32,
options: vec4<u32>, // .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<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = shadow map size, .y = depth bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
}; };
struct ObjectUniform { struct ObjectUniform {
@@ -97,6 +105,10 @@ struct ObjectUniform {
// texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel. // texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel.
@group(2) @binding(0) var texture_sampler: sampler; @group(2) @binding(0) var texture_sampler: sampler;
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>; @group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
// É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 { struct VertexOutput {
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,
@@ -189,6 +201,37 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor; 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<f32>(lit, in.color.a); return vec4<f32>(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>) -> 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<f32>(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<f32>(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>(f32(ox), f32(oy)) * texel;
lit_count += textureSampleCompare(
shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias);
}
}
return lit_count / 9.0;
}
+29
View File
@@ -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. /// 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"); 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. /// Default application title displayed in the OS taskbar/window decorations.
pub const APP_DEFAULT_TITLE: &str = "WSG App"; pub const APP_DEFAULT_TITLE: &str = "WSG App";
+4 -2
View File
@@ -13,6 +13,8 @@ pub mod conf;
pub mod error; pub mod error;
// Re-exports // Re-exports
pub use conf::STANDARD_SHADER; pub use conf::{
pub use conf::STANDARD_SHADER_PATH; SHADOW_MAP_SIZE, SHADOW_SCENE_CENTER, SHADOW_SCENE_RADIUS, SHADOW_SHADER, SHADOW_SHADER_PATH,
STANDARD_SHADER, STANDARD_SHADER_PATH,
};
pub use error::WsgError; pub use error::WsgError;
+26
View File
@@ -28,3 +28,29 @@ fn standard_shader_is_valid_wgsl() {
// Contrat : exactement les deux entrées vs_main / fs_main attendues. // Contrat : exactement les deux entrées vs_main / fs_main attendues.
assert!(module.entry_points.len() >= 2, "vs_main + fs_main attendus"); 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");
}