//! # 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 + fog) [816 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) (É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 | Camera view matrix | //! | 64 | proj | mat4x4 | Camera projection matrix | //! | 128 | cam_pos | vec4 | Camera world position (.xyz) | //! | 144 | ambient | vec4 | Ambient hemisphere color (.rgb) | //! | 160 | lights[0..MAX] | array | 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 | world → light clip space (D3) | //! | | shadow_params | vec4 | .x = map size, .y = depth bias | //! | | options | vec4 | .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| 0 | //! | 1 | normal | vec3| 12 | //! | 2 | uv | vec2| 24 | //! | 3 | color | vec4| 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, @location(1) normal: vec3, @location(2) uv: vec2, @location(3) color: vec4, }; // É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, color: vec4, // rgb = color; a = intensity radius: vec4, // x = point/spot attenuation radius dir_angle: vec4, // spot: xyz = cone axis, w = cos(half-angle) }; struct FrameUniforms { view: mat4x4, proj: mat4x4, cam_pos: vec4, ambient: vec4, // .rgb = ambient hemisphere color lights: array, // 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, // world → shadow light clip space (Étape 14, D3) shadow_params: vec4, // .x = map size, .y = constant bias, .z = slope bias options: vec4, // .x = unlit flag ; .y = shadows on fog_a: vec4, // .x=enabled .y=mode .z=near .w=far (Étape 25) fog_b: vec4, // .x=density .y/.z/.w=fog color RGB (Étape 25) }; struct ObjectUniform { model: mat4x4, // 64 bytes (offset 0) emissive: vec4, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR) pbr: vec4, // 16 bytes (offset 80): .x=metallic .y=roughness (Étape 27) }; @group(0) @binding(0) var frame: FrameUniforms; @group(1) @binding(0) var 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; // Étape 27 : normal map (binding 2) + son sampler (binding 3). Placeholder (128,128,255) si absent. @group(2) @binding(2) var normal_texture: texture_2d; @group(2) @binding(3) var normal_sampler: sampler; // É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, @location(0) world_pos: vec3, @location(1) normal: vec3, @location(2) uv: vec2, @location(3) color: vec4, @location(4) tangent: vec3, // Étape 27 : tangente pour normal mapping }; @vertex fn vs_main(input: VertexInput) -> VertexOutput { var out: VertexOutput; let world = object.model * vec4(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( 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; // Étape 27 : tangente approximée par cross(normal, référence) — évite un attribut tangent. // La référence est choisie pour éviter la dégénérescence (normal parallèle à l'axe Y). let ref_dir = select( vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), abs(input.normal.y) > 0.99, ); let tangent_local = normalize(cross(ref_dir, input.normal)); out.tangent = normal_matrix * tangent_local; return out; } @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { // É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 + emissive. if (frame.options.x != 0u) { let emissive_contrib = base * object.emissive.rgb * object.emissive.a; let final_rgb = base + emissive_contrib; return vec4(apply_fog(final_rgb, in.world_pos), 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(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, n); // Étape 22 (6.2): emissive — added to the lit result (independent of lights/shadows). // Zero emissive (default) → no change (non-regression). In HDR, intensity > 1.0 glows. let emissive_contrib = base * object.emissive.rgb * object.emissive.a; let final_rgb = lit + emissive_contrib; return vec4(apply_fog(final_rgb, in.world_pos), in.color.a); } // Étape 25 : distance fog. Blends the final color toward the fog color based on the // fragment's distance from the camera. Three modes: linear, exponential, exponential². // When `fog_a.x == 0` (disabled), returns the input color unchanged — zero cost. fn apply_fog(color: vec3, world_pos: vec3) -> vec3 { if (frame.fog_a.x < 0.5) { return color; } let dist = length(world_pos - frame.cam_pos.xyz); var fog_factor: f32; if (frame.fog_a.y < 0.5) { // Linear: 1.0 at near, 0.0 at far. fog_factor = saturate((frame.fog_a.w - dist) / max(frame.fog_a.w - frame.fog_a.z, 1e-4)); } else if (frame.fog_a.y < 1.5) { // Exponential: exp(-density * distance). fog_factor = exp(-frame.fog_b.x * dist); } else { // Exponential²: exp(-density² * distance²) — sharper cutoff. let d2 = frame.fog_b.x * frame.fog_b.x; fog_factor = exp(-d2 * dist * dist); } let fog_color = frame.fog_b.yzw; return mix(color, fog_color, 1.0 - fog_factor); } // É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. // // Bias strategy : **slope-scaled** — the reference depth is pulled toward the viewer by // `max(constant_bias, slope_bias * (1.0 - abs(dot(n, light_dir))))`. The slope term grows as the // surface becomes perpendicular to the light (grazing angle), where acne is worst. This prevents // the large black patches that a constant bias alone cannot suppress on large flat surfaces. fn compute_shadow(world_pos: vec3, normal: vec3) -> 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(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(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 texel = 1.0 / max(frame.shadow_params.x, 1.0); // Slope-scaled bias (fixes the large acne patches on surfaces at grazing angles to the light). // Direction from surface toward the shadow-casting light: // directional → position_dir.xyz (already the surface→light direction) // spot → normalize(light_position - world_pos) let sl_idx = frame.shadow_light_index; let sl = frame.lights[sl_idx]; let is_dir = (sl_idx < frame.num_directional); var light_dir: vec3; if (is_dir) { light_dir = normalize(sl.position_dir.xyz); } else { light_dir = normalize(sl.position_dir.xyz - world_pos); } // The slope factor: 0 when the normal faces the light (no bias needed), 1 when perpendicular. let slope = 1.0 - abs(dot(normalize(normal), light_dir)); let bias = max(frame.shadow_params.y, frame.shadow_params.z * slope); // 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(ox), f32(oy)) * texel; lit_count += textureSampleCompare( shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias); } } return lit_count / 9.0; } // ============================================================================ // Étape 27 : PBR Cook-Torrance (GGX + Smith + Schlick) + IBL hémisphère + normal mapping // ============================================================================ const PI: f32 = 3.14159265; // GGX/Trowbridge-Reitz distribution : contrôle la largeur du lobe spéculaire. fn distribution_ggx(ndh: f32, roughness: f32) -> f32 { let a = roughness * roughness; let a2 = a * a; let d = ndh * ndh * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); } // Smith visibility (GGX correlated) : occlusion microsurface. fn geometry_smith(ndh: f32, ndv: f32, ndl: f32, roughness: f32) -> f32 { let a2 = roughness * roughness; // Heuristic : approxime D * V / 4 (voir "A Practical Improvement to the Direct // Analytic Approximation of the Smith Microsurface Model"). let gv = ndl / (ndv * (1.0 - a2) + a2); let gl = ndv * (ndl * (1.0 - a2) + a2); return 0.5 * min(gv, gl); } // Fresnel-Schlick : interpolation entre F0 et 1 selon l'angle de vue. fn fresnel_schlick(hv: f32, f0: vec3) -> vec3 { return f0 + (vec3(1.0) - f0) * pow(1.0 - hv, 5.0); } // BRDF PBR complet : diffuse (Lambert × (1-metallic) × (1-F)) + spéculaire (D×G×F). fn brdf_pbr(n: vec3, v: vec3, l: vec3, base: vec3, metallic: f32, roughness: f32) -> vec3 { let h = normalize(v + l); let f0 = mix(vec3(0.04), base, metallic); let ndl = max(dot(n, l), 0.0); let ndv = max(dot(n, v), 0.0); let ndh = max(dot(n, h), 0.0); let hv = max(dot(h, v), 0.0); let d = distribution_ggx(ndh, roughness); let g = geometry_smith(ndh, ndv, ndl, roughness); let f = fresnel_schlick(hv, f0); // Diffuse : Lambert × (1 - F) × (1 - metallic) — énergie conservée. let kd = (vec3(1.0) - f) * (1.0 - metallic); let diffuse = kd * base / PI; // Speculaire : D × G × F / (4 × N·V × N·L) let denom = 4.0 * ndv * ndl + 1e-4; let specular = d * g * f / denom; return (diffuse + specular) * ndl; } // IBL hémisphérique analytique : sky/ground mix + spéculaire approximé par roughness. fn compute_ibl(n: vec3, base: vec3, metallic: f32, roughness: f32) -> vec3 { let ambient = frame.ambient.rgb; let sky = ambient; let ground = ambient * 0.3; let ibl_diffuse = mix(ground, sky, n.y * 0.5 + 0.5); // Diffuse IBL : Lambert × (1 - metallic) × IBL color let f0 = mix(vec3(0.04), base, metallic); let f = fresnel_schlick(0.0, f0); let kd = (vec3(1.0) - f) * (1.0 - metallic); let diffuse = kd * base * ibl_diffuse / PI; // Speculaire IBL : approximation — plus la roughness est faible, plus le spéculaire est "vif". let spec_ibl = mix(ibl_diffuse, vec3(1.0), (1.0 - roughness) * 0.5); let specular = f * spec_ibl * (0.1 + 0.4 * (1.0 - roughness)); return diffuse + specular; } // Normal mapping : construit la normale perturbée à partir du TBN + normal map. // La tangente vient du vertex shader (cross produit avec une référence anti-dégénérescence). fn compute_pbr_normal(in: VertexOutput) -> vec3 { let n = normalize(in.normal); let t = normalize(in.tangent); let b = normalize(cross(n, t)); let tbn = mat3x3(t, b, n); // Échantillonner la normal map (placeholder 128,128,255 → nmap = (0,0,1) → aucun effet). let nmap = textureSample(normal_texture, normal_sampler, in.uv).rgb * 2.0 - 1.0; return normalize(tbn * nmap); } // Fragment PBR complet : IBL + lumières (BRDF Cook-Torrance) + emissive + fog. @fragment fn fs_pbr(in: VertexOutput) -> @location(0) vec4 { let texel = textureSample(diffuse_texture, texture_sampler, in.uv); let base = texel.rgb * in.color.rgb; // Unlit mode (identique à fs_main). if (frame.options.x != 0u) { let emissive_contrib = base * object.emissive.rgb * object.emissive.a; let final_rgb = base + emissive_contrib; return vec4(apply_fog(final_rgb, in.world_pos), in.color.a); } let metallic = object.pbr.x; let roughness = clamp(object.pbr.y, 0.045, 1.0); // Normal mapping (derivative tangent + normal map texture). let n = compute_pbr_normal(in); let v = normalize(frame.cam_pos.xyz - in.world_pos); // IBL (hémisphère analytique). var color = compute_ibl(n, base, metallic, roughness); // Lumières directionnelles. for (var i = 0u; i < frame.num_directional; i++) { let l = normalize(frame.lights[i].position_dir.xyz); let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a; let shadow = compute_shadow(in.world_pos, n); color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow; } // Lumières ponctuelles. 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 falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0); let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a * falloff; let shadow = compute_shadow(in.world_pos, n); color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow; } // Lumières spot. 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); 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); let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a * falloff * spot_factor; let shadow = compute_shadow(in.world_pos, n); color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow; } // Emissive. let emissive_contrib = base * object.emissive.rgb * object.emissive.a; let final_rgb = color + emissive_contrib; return vec4(apply_fog(final_rgb, in.world_pos), in.color.a); }