fix(shadow): make Étape 14 shadow visible

The shadow pass was correct but the demo light was much too steep (52°
elevation), so the blocker's shadow fell in a ~0.5-unit sliver tight against
the cube's base and was invisible against the bright ground (offscreen pixel
probe found a single dark pixel). Verified with an offscreen probe using the
real Renderer::render_scene + shadow path:
  - steep front light   (0.6,1.1,0.6)  -> 1 dark pixel   (no visible shadow)
  - shallow side light  (1.0,0.3,0.0)  -> 17 107 pixels  (shadow pipeline OK)
  - tuned front-right   (1.0,0.5,0.0)  -> 16 979 pixels  (clear visible shadow)

The azimuth matters most: from the elevated front-right camera, a shadow cast
toward -z falls behind the cube and is occluded; one cast toward -x runs across
the ground to the left of the cube and reads clearly. Tuned light therefore sits
front-right and low (toward_light (1.0,0.5,0.0)), keeping the front faces lit
while casting a clearly visible PCF-softened shadow.

Also reapply the LessEqual comparison sampler fix (commit 39167ee had set it,
but was later reverted to GreaterEqual by 9a51ff7 while debugging; the probe
confirms LessEqual is the correct, non-inverted test). Correct 'rotating cube'
to 'cube' in README/ROADMAP (shadow_test scene is static).
This commit is contained in:
Jérôme Bousquié
2026-09-19 21:12:25 +02:00
parent bfe68f4393
commit ab13fa725e
4 changed files with 21 additions and 14 deletions
+9 -4
View File
@@ -7,7 +7,9 @@
//! directions are easy to read:
//!
//! 1. the **blocker** (cube) casts a directional shadow that stretches along
//! the ground opposite the light direction,
//! the ground opposite the light direction. The light sits at the camera's
//! front-right and low-ish, so its shadow runs clearly across the ground to
//! the left of the cube and is easy to see,
//! 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.
@@ -118,9 +120,12 @@ impl wsg_lib::AppHandler for ShadowTest {
// 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();
// Direction "from surface toward the light": the light sits up and to the +x side
// (the camera's right), at a lowish elevation. Its shadow is then cast toward -x,
// running clearly across the ground to the left of the cube. A steeper or more
// frontal light would push the shadow tight against the cube's base or behind it,
// where it is occluded by the cube from this elevated front-right view.
let toward_light = Vec3::new(1.0, 0.5, 0.0).normalize();
app.scene
.add_directional_light(toward_light, [1.0, 0.98, 0.92], 1.6)
.unwrap();
+10 -8
View File
@@ -161,14 +161,16 @@ impl Renderer {
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
// The shadow map uses WebGPU `[0,1]` clip depth: the light's orthographic
// projection is built with glam's `directx` (WebGPU) module so NDC z is already in
// [0,1] and matches the `current_depth` computed in the main-pass shader. The
// comparison sampler compares the recorded depth against the reference: a surface is
// LIT when it is no farther from the light than the depth recorded in the map, i.e.
// `stored_depth >= reference` (GreaterEqual). The map is cleared to 1.0 (far), so
// un-blocked texels pass and surfaces behind a blocker fail.
compare: Some(wgpu::CompareFunction::GreaterEqual),
// The shadow map uses WebGPU `[0,1]` clip depth (glam `directx`/WebGPU module), so the
// depth stored in the map and the fragment depth computed in the main-pass shader share
// the same convention (smaller = closer to the light ; the map is cleared to 1.0 = far).
// A surface is LIT when it is no farther from the light than the recorded blocker, i.e.
// `current_depth <= stored_depth`. `textureSampleCompare` returns 1 when the sampler's
// compare function holds for `compare_op(depth_ref, sampled)`, so `LessEqual` is the
// correct choice: `depth_ref (= current_depth - bias) <= stored_depth` → lit. Using
// `GreaterEqual` here inverts the test (shadowed regions render lit, directly-lit
// surfaces self-shadow to black) — the regression seen in the Étape 14 `shadow_test`.
compare: Some(wgpu::CompareFunction::LessEqual),
..Default::default()
});
let shadow_map_layout = create_shadow_map_bind_group_layout(&device);