355 lines
14 KiB
Markdown
355 lines
14 KiB
Markdown
# 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) :
|
||
|
||
```rust
|
||
// 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) :
|
||
|
||
```wgsl
|
||
// 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** :
|
||
|
||
```wgsl
|
||
// 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)
|
||
|
||
```wgsl
|
||
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
|
||
|
||
```wgsl
|
||
@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
|
||
|
||
```rust
|
||
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 :
|
||
|
||
```rust
|
||
// É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(x*freq) * sin(y*freq) → 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
|