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.
This commit is contained in:
Jérôme Bousquié
2026-09-19 16:10:47 +02:00
parent 67bd7af095
commit 9a51ff7602
3 changed files with 26 additions and 12 deletions
+15 -8
View File
@@ -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))
}
+5 -1
View File
@@ -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)
}
}
+6 -3
View File
@@ -162,9 +162,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
var diffuse = vec3<f32>(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>) -> 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<f32>(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);