Files
wsg/lib/src/shaders/standard_shader.wgsl
T
Jérôme Bousquié 9a51ff7602 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.
2026-09-19 16:10:47 +02:00

241 lines
13 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # Standard Shader Module (Phong + diffuse texture)
//!
//! Default lit shading pipeline for WSG. Implements an ambient + directional-diffuse
//! (Phong-style) lighting model with an explicit "unlit" mode so that flat 2D rendering
//! is a special case of the 3D path (see DRAFT décision actée : « 2D ⊂ 3D »).
//! Since Étape 10 (DRAFT D2) the fragment can also sample a diffuse texture whose texel
//! modulates the vertex color (`texel.rgb * in.color.rgb`).
//!
//! ## Uniform Contract
//! 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) |
//! | | 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
//! light**); point lights occupy `lights[num_directional..num_directional + num_point]`
//! (`position_dir.xyz` = world position, `radius.x` = linear attenuation radius); spot lights
//! occupy `lights[num_directional + num_point..]` (`position_dir.xyz` = world position,
//! `dir_angle.xyz` = cone axis from the light toward the scene, `dir_angle.w` = cos of the
//! half-angle). No type flag — the index disambiguates (Étapes 12–13).
//!
//! ## Texturing (Étape 10, D2)
//! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the
//! white 1×1 placeholder (texel = `[1,1,1]`), which is the multiplicative identity: `texel * color`
//! leaves the vertex color unchanged, exactly reproducing the pre-Étape-10 look in both lit and
//! unlit modes. A real texture tints/multiplies the vertex color.
//!
//! ## Vertex Input Layout (matches the full `resources::Vertex` struct, 56-byte stride)
//! | Location | Attribute | Type | Offset (bytes) |
//! |----------|-----------|----------|----------------|
//! | 0 | position | vec3<f32>| 0 |
//! | 1 | normal | vec3<f32>| 12 |
//! | 2 | uv | vec2<f32>| 24 |
//! | 3 | color | vec4<f32>| 32 |
//!
//! ## Entry Points
//! - `@vertex vs_main` : world = model * position ; clip = proj * view * world.
//! - `@fragment fs_main` : base = texel * vertex color; × (ambient + diffuse) when lit, or base when unlit.
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
// Étape 12 (Phase 4.2) : maximum number of lights in the per-frame array. Must match
// `wsg_lib::resources::MAX_LIGHTS`.
const MAX_LIGHTS: u32 = 8u;
// A single light (64 bytes = 4 × vec4). Directional: `position_dir.xyz` = direction from the
// surface toward the light. Point: `position_dir.xyz` = world position, `radius.x` = linear
// attenuation radius. Spot: `position_dir.xyz` = world position, `dir_angle.xyz` = cone axis
// (from the light toward the scene), `dir_angle.w` = cos of the half-angle. The array index
// disambiguates the type (no flag stored).
struct Light {
position_dir: vec4<f32>,
color: vec4<f32>, // rgb = color; a = intensity
radius: vec4<f32>, // x = point/spot attenuation radius
dir_angle: vec4<f32>, // spot: xyz = cone axis, w = cos(half-angle)
};
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
num_directional: u32,
num_point: u32,
num_spot: u32,
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 {
model: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
@group(1) @binding(0) var<uniform> object: ObjectUniform;
// Étape 10 (DRAFT D1) : groupe texture — sampler (0) + texture diffuse (1). Un matériau sans
// 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>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: 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 = frame.proj * frame.view * world;
out.world_pos = world.xyz;
// Model matrix is assumed to contain no non-uniform scale, so the normal is
// transformed by the upper-left 3x3 without needing an inverse-transpose.
// WGSL n'autorise pas un cast mat4x4 -> mat3x3 ; on construit la sous-matrice
// à partir des trois premières colonnes.
let normal_matrix = mat3x3<f32>(
object.model[0].xyz,
object.model[1].xyz,
object.model[2].xyz,
);
out.normal = normal_matrix * input.normal;
out.uv = input.uv;
out.color = input.color;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Étape 10 (D2) : échantillonnage inconditionnel. Le texel module la couleur du vertex
// (base = texel * color). Avec le placeholder blanc (texel = 1), base == vertex color :
// aucune régression pour les matériaux sans texture, en lit comme en unlit.
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let base = texel.rgb * in.color.rgb;
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex telle quelle.
if (frame.options.x != 0u) {
return vec4<f32>(base, in.color.a);
}
let n = normalize(in.normal);
// Ambient hémisphérique : dépend de la composante verticale de la normale (couleur venue
// de frame.ambient, Étape 12 — était codée en dur via la couleur de la lumière avant).
let sky = max(n.y, 0.0);
let ambient = frame.ambient.rgb * (0.3 + 0.4 * sky);
var diffuse = vec3<f32>(0.0);
// Lumières directionnelles (indices 0..num_directional). `position_dir` pointe de la surface
// 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 ndotl = max(dot(n, l), 0.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl;
}
// Lumières ponctuelles (indices num_directional..num_directional + num_point). Atténuation
// linéaire dans le rayon (zéro au-delà).
for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4);
let ndotl = max(dot(n, l), 0.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff;
}
// Lumières spot (indices num_directional + num_point..num_directional + num_point +
// num_spot). Cône orienté : on teste l'alignement de la direction **de la lumière vers le
// point** de la surface (-l, car l pointe de la surface vers la lumière) avec l'axe du cône
// (dir_angle.xyz, de la lumière vers la scène). Pénombre lissée entre le demi-angle intérieur
// (dir_angle.w) et un liseré extérieur (demi-angle − 0.1 rad), plus atténuation linéaire.
let spot_base = frame.num_directional + frame.num_point;
for (var i = spot_base; i < spot_base + frame.num_spot; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4); // surface -> lumière
let ndotl = max(dot(n, l), 0.0);
// direction lumière -> point de la surface = -l ; alignée avec l'axe du cône (dir_angle.xyz).
let to_point = -l;
let cone = dot(to_point, normalize(frame.lights[i].dir_angle.xyz));
let cos_inner = frame.lights[i].dir_angle.w;
let cos_outer = cos_inner - 0.1;
let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor;
}
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 (LessEqual). 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
// 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);
// 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;
}