From ab13fa725eed26aa948c17ddf3469ccd6b24b222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Sat, 19 Sep 2026 21:12:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(shadow):=20make=20=C3=89tape=2014=20shadow?= =?UTF-8?q?=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 2 +- docs/ROADMAP.md | 2 +- lib/examples/shadow_test.rs | 13 +++++++++---- lib/src/core/renderer.rs | 18 ++++++++++-------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 023edf2..cced0b1 100644 --- a/README.md +++ b/README.md @@ -188,4 +188,4 @@ The architecture docs live in `docs/tech/` and are written in **French**. Each d 9. ✅ **Window resize (Étape 11, Phase 4.4)** — `App::resize` reconfigures the surface (`Context::configure`) and recreates the depth texture (`Renderer::resize_depth`) together on each `WindowEvent::Resized`, so color and depth attachments always match. Guards against 0×0 (minimize). The surface format is re-synced to the Renderer and Scene if it ever changes. (Done 2026-09-18; verified at runtime on the `cube` example.) 10. ✅ **Multi-lighting (Étape 12, Phase 4.2)** — the scene now carries a global light list (directional + point) with a white ambient, uploaded into the per-frame `FrameUniforms` array each frame. `Scene::add_directional_light` / `add_point_light` / `set_ambient` / `clear_lights` configure it; `FrameUniforms::default()` (one white directional along +Z + white ambient) reproduces the pre-multi-light look exactly. The `standard` fragment accumulates ambient + all lights; the `cube` example adds a warm point light on top of the default directional. (Done 2026-09-18.) 11. ✅ **Spot lights (Étape 13, Phase 4.2)** — spot lights (oriented cone + half-angle) added on top of the multi-lighting system. `Scene::add_spot_light(pos, dir, color, intensity, radius, half_angle)` registers a spot light; the `standard` fragment accumulates a spot term with a smoothed penumbra (half-angle ± 0.1 rad) and linear attenuation. `Light` grew from 48 to 64 bytes (added `dir_angle`); `FrameUniforms` from 576 to 704 bytes (added `num_spot`). Non-regression: default scene unchanged. The `cube` example adds a green spot light aimed at the cube. (Done 2026-09-18.) -12. ✅ **Shadows — shadow mapping (Étape 14, Phase 4.2, optionnel)** — classic two-pass shadow mapping on a **single** light (directional or spot), selected by `Scene::set_shadow_caster(index)`. A depth-only pass (`shadow_shader.wgsl` + dedicated `shadow_pipeline`) renders the scene into a 1024² `Depth32Float` shadow map (`Renderer`-owned, slope-scaled depth bias); the `standard` fragment re-projects each fragment into light space and applies a **PCF 3×3** comparison-sampler test (bind group **@3**, shared). `FrameUniforms` grew from 704 to 784 bytes (`shadow_light_index`, `light_view_proj`, `shadow_params`). Shadows are **off by default** (`shadow_caster = None`) so `simple`/`cube`/`manual`/`spot_test` are unchanged. The `shadow_test` example casts a soft shadow from a rotating cube onto a ground slab. (Done 2026-09-19.) +12. ✅ **Shadows — shadow mapping (Étape 14, Phase 4.2, optionnel)** — classic two-pass shadow mapping on a **single** light (directional or spot), selected by `Scene::set_shadow_caster(index)`. A depth-only pass (`shadow_shader.wgsl` + dedicated `shadow_pipeline`) renders the scene into a 1024² `Depth32Float` shadow map (`Renderer`-owned, slope-scaled depth bias); the `standard` fragment re-projects each fragment into light space and applies a **PCF 3×3** comparison-sampler test (bind group **@3**, shared). `FrameUniforms` grew from 704 to 784 bytes (`shadow_light_index`, `light_view_proj`, `shadow_params`). Shadows are **off by default** (`shadow_caster = None`) so `simple`/`cube`/`manual`/`spot_test` are unchanged. The `shadow_test` example casts a soft shadow from a cube onto a ground slab. (Done 2026-09-19.) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2367492..3f1cb9a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -146,7 +146,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } + pipeline ombre dans le `Renderer` (shadow map 1024² Depth32Float, bias slope-scaled) ; pass `render_shadow_map` en tête de `render_scene` ; PCF 3×3 + comparateur dans `standard_shader.wgsl` (groupe @3 partagé, lié mais non échantillonné quand désactivé → non-régression). Ombres **éteintes - par défaut**. Exemple `shadow_test` : cube tournant projetant une ombre sur un sol.)* + par défaut**. Exemple `shadow_test` : cube projetant une ombre douce sur un sol.)* ### 4.3 Optimisations - [ ] Batching par Material (réduction des state changes GPU) diff --git a/lib/examples/shadow_test.rs b/lib/examples/shadow_test.rs index 3c3639e..8c626ba 100644 --- a/lib/examples/shadow_test.rs +++ b/lib/examples/shadow_test.rs @@ -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(); diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index 75e650c..796c294 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -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);