460 lines
23 KiB
WebGPU Shading Language
460 lines
23 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 + 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<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 = map size, .y = constant bias, .z = slope bias
|
||
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
|
||
fog_a: vec4<f32>, // .x=enabled .y=mode .z=near .w=far (Étape 25)
|
||
fog_b: vec4<f32>, // .x=density .y/.z/.w=fog color RGB (Étape 25)
|
||
};
|
||
|
||
struct ObjectUniform {
|
||
model: mat4x4<f32>, // 64 bytes (offset 0)
|
||
emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR)
|
||
pbr: vec4<f32>, // 16 bytes (offset 80): .x=metallic .y=roughness (Étape 27)
|
||
};
|
||
|
||
@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 27 : normal map (binding 2) + son sampler (binding 3). Placeholder (128,128,255) si absent.
|
||
@group(2) @binding(2) var normal_texture: texture_2d<f32>;
|
||
@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<f32>,
|
||
@location(0) world_pos: vec3<f32>,
|
||
@location(1) normal: vec3<f32>,
|
||
@location(2) uv: vec2<f32>,
|
||
@location(3) color: vec4<f32>,
|
||
@location(4) tangent: vec3<f32>, // Étape 27 : tangente pour normal mapping
|
||
};
|
||
|
||
@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;
|
||
// É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<f32>(0.0, 1.0, 0.0),
|
||
vec3<f32>(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<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 + emissive.
|
||
if (frame.options.x != 0u) {
|
||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||
let final_rgb = base + emissive_contrib;
|
||
return vec4<f32>(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<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, 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<f32>(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<f32>, world_pos: vec3<f32>) -> vec3<f32> {
|
||
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<f32>, normal: 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 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<f32>;
|
||
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>(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<f32>) -> vec3<f32> {
|
||
return f0 + (vec3<f32>(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<f32>, v: vec3<f32>, l: vec3<f32>,
|
||
base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
|
||
let h = normalize(v + l);
|
||
let f0 = mix(vec3<f32>(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<f32>(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<f32>, base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
|
||
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<f32>(0.04), base, metallic);
|
||
let f = fresnel_schlick(0.0, f0);
|
||
let kd = (vec3<f32>(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<f32>(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<f32> {
|
||
let n = normalize(in.normal);
|
||
let t = normalize(in.tangent);
|
||
let b = normalize(cross(n, t));
|
||
let tbn = mat3x3<f32>(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<f32> {
|
||
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<f32>(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<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
|
||
}
|