É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
+260 -15
View File
@@ -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<usize>,
) {
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<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,
/// 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);