9a51ff7602
- 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.
241 lines
13 KiB
WebGPU Shading Language
241 lines
13 KiB
WebGPU Shading Language
//! # 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;
|
||
}
|