From 9a51ff7602176c64d6e8cb97ae61c906aa31a69e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Sat, 19 Sep 2026 16:10:47 +0200 Subject: [PATCH] Fix shadow_test: WebGPU clip depth, correct NdotL, shadow compare - Use glam's directx (WebGPU) projection module for both the camera perspective and the shadow orthographic: NDC clip depth is [0,1] as wgpu expects, instead of OpenGL's [-1,1] which clipped half the frustum and broke depth-space consistency with the shadow map. - Extend the shadow orthographic far plane to 2*r so the whole scene box (and the shadow cast behind it, toward the camera) is covered. - Switch the shadow comparison sampler to GreaterEqual so open sky is lit and surfaces behind a blocker are shadowed (previous LessEqual inverted the shadow, blackening the entire ground and making the cube float). - Use the surface->light direction (+position_dir) for the directional N*L term; the old negation darkened the cube top and lit the camera faces, producing the inverted-pyramid appearance. - Drop the now-redundant [0,1] depth remap in the main-pass shader. --- lib/src/core/renderer.rs | 23 +++++++++++++++-------- lib/src/resources/camera.rs | 6 +++++- lib/src/shaders/standard_shader.wgsl | 9 ++++++--- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index ca084d8..bf31642 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -161,11 +161,14 @@ impl Renderer { 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). LessEqual = lit when the fragment - // is no farther from the light than the surface recorded in the shadow map (the map is - // cleared to 1.0 = far, so open un-blocked texels pass and surfaces behind a blocker fail). - compare: Some(wgpu::CompareFunction::LessEqual), + // 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), ..Default::default() }); let shadow_map_layout = create_shadow_map_bind_group_layout(&device); @@ -362,10 +365,14 @@ impl Renderer { // 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). + // Orthographic box of half-size r, near 0, far 2·r (D1/D3), in the WebGPU `[0,1]` NDC + // convention (glam `directx` module), which matches the depth range wgpu writes to the + // shadow map and the `current_depth` computed by the main-pass shader. The eye sits one + // scene-radius behind the target, so the box [−r, r] around the target spans a depth range + // of [0, 2r] from the eye: `far = 2·r` covers the whole box (and the shadows cast behind + // it), whereas `far = r` would clip the far half. let proj = - glam::camera::rh::proj::opengl::orthographic(-r, r, -r, r, 0.0, r); + glam::camera::rh::proj::directx::orthographic(-r, r, -r, r, 0.0, 2.0 * r); Some((index, proj * view)) } diff --git a/lib/src/resources/camera.rs b/lib/src/resources/camera.rs index c668833..73a2e2f 100644 --- a/lib/src/resources/camera.rs +++ b/lib/src/resources/camera.rs @@ -91,6 +91,10 @@ impl Camera { /// # Returns /// A `Mat4` representing the projection transformation matrix (view → clip space) pub fn projection_matrix(&self, aspect: f32) -> Mat4 { - glam::camera::rh::proj::opengl::perspective(self.fov, aspect, self.near, self.far) + // WebGPU expects NDC clip depth in [0,1]; glam's `opengl` module remaps to [-1,1], which + // would clip roughly the front half of the frustum in wgpu. The `directx` (WebGPU) module + // produces Y-up right-handed projections with depth already in [0,1], matching the shadow + // projections and the depth wgpu writes. + glam::camera::rh::proj::directx::perspective(self.fov, aspect, self.near, self.far) } } diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index 24c2625..d33f03e 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -162,9 +162,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { var diffuse = vec3(0.0); // Lumières directionnelles (indices 0..num_directional). `position_dir` pointe de la surface - // vers la lumière ; on l'inverse pour le terme N·L. + // vers la lumière, donc on l'utilise tel quel pour le terme N·L (dot(n, direction vers la + // lumière) > 0 = face éclairée). for (var i = 0u; i < frame.num_directional; i++) { - let l = normalize(-frame.lights[i].position_dir.xyz); + let l = normalize(frame.lights[i].position_dir.xyz); let ndotl = max(dot(n, l), 0.0); diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl; } @@ -220,7 +221,9 @@ fn compute_shadow(world_pos: vec3) -> f32 { 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(shadow_uv.x, 1.0 - shadow_uv.y); // flip V for texture coordinates - let current_depth = shadow_ndc.z * 0.5 + 0.5; + // The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam + // directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed. + let current_depth = shadow_ndc.z; let bias = frame.shadow_params.y; let texel = 1.0 / max(frame.shadow_params.x, 1.0);