É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
+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;
}
+60 -17
View File
@@ -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<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) :
//! | Offset | Field | Type | Meaning |
//! |-----------------------|----------------|---------------|----------------------------------|
//! | 0 | view | mat4x4<f32> | Camera view matrix |
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
//! | 160 + 64·MAX_LIGHTS | num_directional| u32 | # directional (indices 0..n) |
//! | | num_point | u32 | # point (indices n..) |
//! | | num_spot | u32 | # spot (indices after point) |
//! | | options | vec4<u32> | x = unlit flag (1 => flat color) |
//! | Offset | Field | Type | Meaning |
//! |-----------------------|-------------------|---------------|----------------------------------|
//! | 0 | view | mat4x4<f32> | Camera view matrix |
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
//! | 160 + 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<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
//! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the
@@ -79,12 +84,15 @@ struct FrameUniforms {
view: mat4x4<f32>,
proj: mat4x4<f32>,
cam_pos: vec4<f32>,
ambient: vec4<f32>, // .rgb = ambient hemisphere color
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
ambient: vec4<f32>, // .rgb = ambient hemisphere color
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
num_directional: u32,
num_point: u32,
num_spot: u32,
options: vec4<u32>, // .x : unlit flag (1 = flat color, no lighting)
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 {
@@ -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<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 {
@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;
}
let lit = base * (ambient + diffuse);
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos);
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;
}