Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 54a482e354 PBR
2026-09-25 14:40:47 +02:00

14 KiB
Raw Blame History

DRAFT — Étape 27 : PBR Metallic/Roughness + Normal Mapping (Phase 6.5)

Contexte

Le shader actuel (standard_shader.wgsl) utilise un modèle d'éclairage simpliste :

  • Diffuse Lambert (N·L) + ambient hémisphérique
  • Aucun terme spéculaire (pas de Blinn-Phong, pas de Cook-Torrance)
  • Pas de normal mapping
  • Pas d'IBL (Image-Based Lighting)

Résultat : les matériaux métalliques ne brillent pas, les surfaces rugueuses ne s'assombrissent pas correctement, et les normales ne peuvent pas être sculptées via texture. Le saut vers PBR est le plus grand gain visuel restant.

Objectif

Remplacer le modèle Lambert par un PBR Metalness/Roughness complet :

  • BRDF Cook-Torrance (GGX distribution + Smith visibility + Schlick Fresnel)
  • Workflow Metallic/Roughness (industriel : Unreal, Unity, Blender)
  • Normal mapping (tangent space, tangente dérivée — pas d'attribut tangent)
  • IBL analytique (hémisphère ciel/sol, pas de cubemap)
  • Rétrocompatibilité : les matériaux existants (metallic=0, roughness=0.5) rendent à peu près comme avant (diffuse + léger spéculaire)

Décisions

D1 — Workflow Metallic/Roughness

F0 = mix(vec3(0.04), base_color, metallic)   // diélectrique: 4% reflexion, métal: albedo
R = roughness²  (GGX alpha)
  • metallic ∈ [0, 1] : 0 = diélectrique (dielectric), 1 = métal pur
  • roughness ∈ [0, 1] : 0 = miroir, 1 = totalement rugueux
  • Le base_color existant sert d'albedo (déjà présent via texture + vertex color)
  • Pas de Specular/Glossiness (workflow obsolète)

D2 — Où stocker metallic/roughness

Dans le padding de ObjectUniform (offset 80-87, juste après emissive à 64-79) :

// WGSL:
struct ObjectUniform {
    model:    mat4x4<f32>,  // 64 bytes (offset 0)
    emissive: vec4<f32>,    // 16 bytes (offset 64)
    pbr:      vec4<f32>,    // 16 bytes (offset 80): (metallic, roughness, 0, 0)
    // ... padding jusqu'à 256 bytes
};
  • Aucune modification du compute shader (il n'écrit que bytes 0-63)
  • Ecrit via queue.write_buffer au moment du frame update (comme l'emissive)
  • Material gagne 2 champs : metallic: f32, roughness: f32

D3 — Nouveau point d'entrée shader fs_pbr

Le PBR est plus complexe que le Lambert actuel. Plutôt que de modifier fs_main en place (risque de régression), on ajoute un deuxième point d'entrée fragment fs_pbr dans le même module WGSL :

@fragment fn fs_main(...)  → Lambert (existant, pour rétrocompatibilité)
@fragment fn fs_pbr(...)   → PBR Cook-Torrance (nouveau)

La sélection est compile-time via le shader_id :

  • Material::new(format, "standard", cache) → pipeline avec fs_main (Lambert)
  • Material::pbr(format, cache) → pipeline avec fs_pbr (PBR)

Le vertex shader est partagé entre les deux (même vs_main).

D4 — Normal mapping par tangente dérivée

Pas d'attribut tangent dans le vertex buffer (casserait tous les meshes existants). On utilise la méthode des dérivées ecran-space (mipmapped derivative tangent) :

// Dans le fragment shader :
let dpdx = dFdx(world_pos);
let dpdy = dFdy(world_pos);
let dwdx = dFdx(uv);
let dwdy = dFdy(uv);

let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y);
let bitangent = normalize(cross(n, tangent));
let tbn = mat3x3<f32>(tangent, bitangent, n);

Avantages :

  • Zéro changement de format vertex
  • Fonctionne avec n'importe quel mesh existant
  • Moins précis qu'un tangent explicite (artefacts possibles sur UV dégénérés)
  • Suffisant pour un premier PBR

Le normal map est échantillonné dans @group(2) @binding(2) (nouveau binding) :

vec3 nmap = textureSample(normal_texture, normal_sampler, uv).rgb * 2.0 - 1.0;
vec3 n_pbr = normalize(tbn * nmap);

Sans normal map → placeholder blanc (128,128,255) → nmap = (0,0,1) → n_pbr = n (aucun changement).

D5 — IBL analytique (hémisphère)

Pas de cubemap pour cette étape. L'IBL est approximé par un hémisphère 2 couleurs :

// Sky/ground colors from frame.ambient (déjà présent)
let ibl_dir = n;  // direction de la normale (view space ou world)
let ibl_sky = frame.ambient.rgb;           // couleur "ciel"
let ibl_ground = frame.ambient.rgb * 0.3;  // couleur "sol" (assombrie)
let ibl_color = mix(ibl_ground, ibl_sky, ibl_dir.y * 0.5 + 0.5);

// Specular IBL : approximation pré-filtrée par roughness
// (réalité : cubemap pré-filtrée par mip ; ici : simple interpolation)
let spec_ibl = mix(ibl_color, vec3<f32>(1.0), 0.5 * (1.0 - roughness));

C'est une approximation grossière mais suffisante pour :

  • Donner du "remplissage" aux zones non éclairées par les lumières ponctuelles
  • Faire varier le spéculaire IBL selon la roughness (mirroir = brillant, rugueux = mat)

D6 — BRDF Cook-Torrance (GGX)

fn distribution_ggx(n: vec3<f32>, h: vec3<f32>, roughness: f32) -> f32 {
    let a = roughness * roughness;
    let a2 = a * a;
    let ndh = max(dot(n, h), 0.0);
    let d = ndh * ndh * (a2 - 1.0) + 1.0;
    return a2 / (3.14159 * d * d);
}

fn geometry_smith(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>, roughness: f32) -> f32 {
    let a = roughness * roughness;
    let kv = vec2<f32>(0.5, 0.5);
    let gv = n.y / (n.y * (1.0 - kv.y) + kv.x);  // note: n.y ≈ |N·V| pour hémisphère local
    let kv2 = vec2<f32>(0.5, 0.5);
    let gl = n.y / (n.y * (1.0 - kv2.y) + kv2.x);
    return gv * gl;
}

fn fresnel_schlick(cos_theta: f32, f0: vec3<f32>) -> vec3<f32> {
    return f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - cos_theta, 5.0);
}

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 d = distribution_ggx(n, h, roughness);
    let g = geometry_smith(n, v, l, roughness);
    let f = fresnel_schlick(max(dot(h, v), 0.0), f0);
    
    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);
    
    // Diffuse : Lambert × (1 - metallic) × (1 - F_D90)
    let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
    let diffuse = kd * base / 3.14159;
    
    // Speculaire : D × G × F / (4 × N·V × N·L)
    let denom = 4.0 * ndv * ndl + 1e-4;
    let specular = d * g * f / denom;
    
    let radiance = (diffuse + specular) * base * ndl;  // base = light color × intensity
    return radiance;
}

D7 — Structure du fragment PBR

@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 (même que fs_main)
    if (frame.options.x != 0u) {
        let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
        return vec4<f32>(apply_fog(base + emissive_contrib, in.world_pos), in.color.a);
    }

    let metallic = object.pbr.x;
    let roughness = clamp(object.pbr.y, 0.045, 1.0);  // min 0.045 (évite division par 0)

    // Normal mapping (derivative tangent)
    let n = compute_pbr_normal(in);  // inclut le normal map si présent

    let v = normalize(frame.cam_pos - in.world_pos);
    var color = vec3<f32>(0.0);

    // IBL (hémisphère analytique)
    let ibl = compute_ibl(n, roughness, base, metallic);
    color += ibl;

    // 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;
        color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color
               * compute_shadow(in.world_pos, n);
    }

    // Lumières ponctuelles + spots (même pattern, avec falloff)
    // ...

    // 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);
}

D8 — Texture normal map : nouveau binding @group(2) @binding(2)

Le @group(2) actuel a 2 bindings (sampler + diffuse texture). On ajoute :

@group(2) @binding(2) var normal_texture: texture_2d<f32>;
@group(2) @binding(3) var normal_sampler: sampler;
  • Sans normal map → placeholder (128,128,255) = normale neutre → aucun effet
  • Le Material gagne un champ normal_texture: Option<Arc<Texture>>
  • Le bind group group-2 est reconstruit avec la normal map (ou le placeholder)
  • Le pipeline layout est le même pour fs_main et fs_pbr (mêmes bindings) → la PipelineCache peut partager le layout

D9 — Material::pbr() constructor

impl Material {
    /// Crée un matériau PBR avec metallic/roughness.
    pub fn pbr(
        format: wgpu::TextureFormat,
        shader_id: &str,  // "pbr"
        metallic: f32,
        roughness: f32,
        cache: &mut PipelineCache,
    ) -> Self { ... }

    /// Avec texture albedo + normal map.
    pub fn pbr_textured(
        format: wgpu::TextureFormat,
        shader_id: &str,
        metallic: f32,
        roughness: f32,
        albedo: Option<Arc<Texture>>,
        normal_map: Option<Arc<Texture>>,
        cache: &mut PipelineCache,
    ) -> Self { ... }
}

D10 — Rétrocompatibilité

  • Material::new() (existant) → pipeline fs_main (Lambert) → inchangé
  • Material::pbr() (nouveau) → pipeline fs_pbr (PBR) → nouveau
  • Les deux pipelines coexistent dans la PipelineCache
  • Les examples existants (demo, bloom, fog, dof, etc.) continuent à utiliser Material::new()
  • Aucune régression : le shader fs_main n'est pas modifié

D11 — Pipeline layout : 1 seul layout pour les 2 entry points

fs_main et fs_pbr lisent les mêmes bindings :

  • @group(0): FrameUniforms
  • @group(1): ObjectUniform
  • @group(2): sampler + diffuse + normal_sampler + normal_texture

Un seul BindGroupLayout couvre les deux. La PipelineCache crée 2 pipelines (même layout, entry points différents) → partage du layout = zéro overhead supplémentaire.

D12 — ObjectUniform : écriture du PBR data

Dans render_scene, l'écriture de l'emissive est déjà faite par queue.write_buffer à l'offset 64. On ajoute l'écriture de pbr à l'offset 80 :

// Étape 27 : PBR params (metallic, roughness) dans le padding de ObjectUniform.
if mat.metallic != 0.0 || mat.roughness != 0.5 {
    let pbr_data: [f32; 4] = [mat.metallic, mat.roughness, 0.0, 0.0];
    let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 80) as u64;
    self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&pbr_data));
}

Par défaut (metallic=0, roughness=0.5) → pas d'écriture → le buffer contient 0.0 (le buffer est alloué avec COPY_DST et initialisé à zéro) → c'est correct : metallic=0 (diélectrique) et roughness=0.0...

Hmm, roughness=0.0 est un problème (GGX avec alpha=0 → division par zéro). Solution : clamer roughness = max(roughness, 0.045) dans le shader (déjà prévu en D7). Le buffer initialisé à 0 → roughness=0 → clampé à 0.045 dans le shader → OK.

D13 — Example pbr.rs

Scène de démonstration :

  • Sol : plan 20×20, PBR (metallic=0, roughness=0.8) — surface matte
  • Cube métal : metallic=1.0, roughness=0.1 — miroir chromé
  • Cube plastique : metallic=0.0, roughness=0.4 — plastique lisse
  • Cube rouillé : metallic=0.8, roughness=0.7 — métal rugueux
  • Sphere : metallic=0.3, roughness=0.3 — céramique
  • Cube normal map : avec une normal map procédurale (bump)
  • 1 lumière directionnelle + 1 spot
  • Clavier : R = reset, 1 = varier roughness, 2 = varier metallic

D14 — Normal map procédurale pour l'exemple

Générer une texture normal map 256×256 en code (pas de fichier externe) :

  • Pattern "bump" : sin(xfreq) * sin(yfreq) → normale perturbée
  • Ou pattern "bricks" : normales plates avec arêtes
  • Stockée dans un wgpu::Texture via queue.write_texture

Étapes d'implémentation

# Tâche Fichiers
1 Material : ajouter metallic, roughness, normal_texture + constructors pbr()/pbr_textured() resources/material.rs
2 ObjectUniform WGSL : ajouter pbr: vec4<f32> (offset 80) shaders/standard_shader.wgsl
3 Écrire le BRDF Cook-Torrance (GGX + Smith + Schlick) en WGSL shaders/standard_shader.wgsl
4 Écrire fs_pbr (IBL + boucle lumières + normal map) shaders/standard_shader.wgsl
5 Normal map bindings @group(2) @binding(2,3) + placeholder shaders/standard_shader.wgsl + pipeline_cache.rs
6 PipelineCache : créer pipeline fs_pbr (même layout, entry point différent) pipeline/pipeline_cache.rs
7 Renderer : écrire pbr data dans ObjectUniform (offset 80) core/renderer.rs
8 Bind group group-2 : inclure normal map (ou placeholder) resources/material.rs
9 WGSL validation test : vérifier que fs_pbr parsse tests/wgsl_validate.rs
10 Example pbr.rs : scène de démo + normal map procédurale examples/pbr.rs
11 Docs : examples/README.md + docs/user/pbr.md + ROADMAP divers

Risques et mitigations

Risque Mitigation
GGX avec roughness≈0 → NaN Clamp roughness ≥ 0.045 dans le shader
Dérivées ecran-space instables sur UV dégénérés (poles, seams) Acceptable pour v1 ; tangent explicite en v2
Le PBR est "trop sombre" vs Lambert Le base/π dans le diffuse PBR assombrit ; compenser par lumière plus intense ou exposure
Normal map placeholder (128,128,255) → artefacts sur certains angles Le mat3 TBN est orthonormalisé par normalize ; acceptable
2 pipelines (fs_main + fs_pbr) → mémoire GPU ~2 pipelines × ~50KB = négligeable

Critères d'acceptation

  • Material::pbr(format, "pbr", metallic, roughness, cache) compile et rend
  • Un cube metallic=1, roughness=0.1 a un reflet spéculaire net (miroir)
  • Un cube metallic=0, roughness=0.9 a un spéculaire large et diffus (mat)
  • Un cube avec normal map montre des bumps visibles
  • Les examples existants (demo, bloom, fog, dof) sont inchangés (fs_main)
  • cargo test --workspace : 0 failures
  • cargo check -p wsg-lib --all-targets : 0 warnings