This commit is contained in:
Jérôme Bousquié
2026-09-25 14:40:47 +02:00
parent 8ece89ccba
commit 54a482e354
12 changed files with 866 additions and 370 deletions
+285 -355
View File
@@ -1,424 +1,354 @@
# Étape 26 — Depth of Field (DoF)
# DRAFT — Étape 27 : PBR Metallic/Roughness + Normal Mapping (Phase 6.5)
> **Objectif** : Flou de profondeur post-process — les objets hors de la distance
> de focus sont flous, créant un effet cinématique. Opt-in via `with_dof()`,
> zéro coût quand désactivé.
## 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)
## Contexte & motivation
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.
Le DoF (Depth of Field) simule le comportement d'un objectif photo : seuls les
objets à la distance de focus sont nets, le reste est flou. Utilité :
## Objectif
- **Effet cinématique** — mettre en scène un objet/personnage
- **Guidage du regard** — diriger l'attention du joueur
- **Masquage subtil** — flou les zones non pertinentes (alternative douce au fog)
### Pipeline existant (avec HDR)
```text
Main pass → HDR texture (Rgba16Float)
↓
Bloom (si actif) → composite
↓
Tone Mapping → surface
```
### Pipeline avec DoF
```text
Main pass → HDR texture + depth buffer
↓
Bloom (si actif) → bloom_composite
↓
DoF (si actif) :
CoC pass: depth → coc_texture (R16F, radius en px)
Blur pass: color + coc → dof_output (Rgba16F)
↓
Tone Mapping → surface
```
Quand DoF est désactivé : TM lit directement la texture HDR/bloom (zéro coût).
---
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 — 2 passes : CoC + Blur
### D1 — Workflow Metallic/Roughness
| Pass | Entrées | Sortie | Format |
|------|---------|--------|--------|
| CoC | depth texture | coc_texture | `R16Float` (1 canal, radius en pixels) |
| Blur | color + coc | dof_output | `Rgba16Float` (4 canaux, couleur floutée) |
Le CoC est calculé séparément pour éviter de recalculer la linearisation du
depth dans chaque tap du blur.
### D2 — Formule du CoC
```wgsl
// Linearize NDC depth [0,1] → world distance (perspective)
fn linearize_depth(ndc_z: f32, near: f32, far: f32) -> f32 {
return near * far / (far - ndc_z * (far - near));
}
// CoC in pixels:
let dist = linearize_depth(depth, near, far);
let coc = max_blur * aperture * abs(dist - focus_distance) / max(focus_distance, 1e-4);
coc = min(coc, max_blur);
```
F0 = mix(vec3(0.04), base_color, metallic) // diélectrique: 4% reflexion, métal: albedo
R = roughness² (GGX alpha)
```
- `focus_distance` : distance (unités monde) où l'image est parfaitement nette
- `aperture` : 0.0–1.0, contrôle l'intensité du flou (0 = pas de flou)
- `max_blur` : radius maximum en pixels (clamp, évite le flou excessif)
- `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)
### D3 — Uniform struct (32 bytes)
### D2 — Où stocker metallic/roughness
```wgsl
struct DoFUniform {
focus_distance: f32, // world units
aperture: f32, // 0.0-1.0
max_blur: f32, // pixels
near: f32, // camera near plane
far: f32, // camera far plane
inv_width: f32, // 1.0 / texture width
inv_height: f32, // 1.0 / texture height
_pad: f32,
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
};
```
Un seul uniform partagé entre les 2 passes (CoC et Blur) — les valeurs sont
identiques. Pas de ping-pong de buffers.
- **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`
### D4 — Blur : disc 12-tap
### D3 — Nouveau point d'entrée shader `fs_pbr`
Le blur utilise un pattern de 12 échantillons en disque (poisson-like),
scallé par le CoC local :
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 :
```text
· ·
· ·
· ·
· · ·
· ·
· ·
· ·
```
@fragment fn fs_main(...) → Lambert (existant, pour rétrocompatibilité)
@fragment fn fs_pbr(...) → PBR Cook-Torrance (nouveau)
```
Chaque tap : `offset * coc_radius * texel_size`, pondéré uniformément (1/12).
Le radius variable (par pixel) donne un bokeh naturel.
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)
> Pourquoi pas separable H+V comme bloom ? Le DoF produit un flou **circulaire**
> (bokeh), pas un flou directionnel. Un disc blur single-pass est plus fidèle.
> 12 taps × 1 texture = trivial GPU cost.
Le vertex shader est **partagé** entre les deux (même `vs_main`).
### D5 — Textures
### D4 — Normal mapping par tangente dérivée
| Texture | Format | Taille | Quand allouée |
|---------|--------|--------|---------------|
| `coc_texture` | `R16Float` | full-res (w×h) | DoF actif |
| `dof_output` | `Rgba16Float` | full-res (w×h) | DoF actif |
Quand DoF est désactivé : **aucune** texture DoF n'est allouée. Zéro coût.
### D6 — API publique
```rust
/// Configuration du Depth of Field.
#[derive(Clone, Copy, Debug)]
pub struct DoFConfig {
/// Distance de focus (unités monde). L'image est nette à cette distance.
pub focus_distance: f32,
/// Intensité du flou (0.0 = aucun, 1.0 = max).
pub aperture: f32,
/// Radius maximum du flou en pixels.
pub max_blur: f32,
}
impl DoFConfig {
/// DoF standard : focus à `distance`, flou modéré.
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self;
/// Preset cinématique : flou prononcé, max_blur=12px.
pub fn cinematic(focus_distance: f32) -> Self;
/// Preset subtil : léger flou en arrière-plan, max_blur=6px.
pub fn subtle(focus_distance: f32) -> Self;
}
```
**Builder** :
```rust
AppBuilder::with_dof(DoFConfig::cinematic(5.0))
```
**Runtime** :
```rust
app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0)));
app.renderer_mut().set_dof(None); // désactiver
```
### D7 — Pipeline integration
Dans `Renderer::render_scene` :
```rust
// Après bloom (ou après main pass si pas de bloom) :
if let Some(dof) = &self.dof_pipeline {
// 1. CoC pass
let mut coc_pass = encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &dof.coc_view,
resolve_target: None,
ops: ColorOps::ALL,
format: TextureFormat::R16Float,
..
})],
depth_stencil_attachment: None,
..
});
coc_pass.set_pipeline(&dof.coc_pipeline);
coc_pass.set_bind_group(0, &dof.coc_bind_group, &[]);
coc_pass.draw(0, 3, 0, 1);
drop(coc_pass);
// 2. Blur pass
let mut blur_pass = encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &dof.output_view,
resolve_target: None,
ops: ColorOps::ALL,
format: TextureFormat::Rgba16Float,
..
})],
depth_stencil_attachment: None,
..
});
blur_pass.set_pipeline(&dof.blur_pipeline);
blur_pass.set_bind_group(0, &dof.blur_bind_group, &[]);
blur_pass.draw(0, 3, 0, 1);
drop(blur_pass);
// 3. TM lit dof_output au lieu de HDR
// (re-pointer le bind group TM)
}
```
### D8 — Shaders
#### `dof_coc.wgsl`
**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
// Vertex : fullscreen triangle (identique à TM/bloom)
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
// même triangle que TM : (-1,-1), (3,-1), (-1,3)
}
// Dans le fragment shader :
let dpdx = dFdx(world_pos);
let dpdy = dFdy(world_pos);
let dwdx = dFdx(uv);
let dwdy = dFdy(uv);
struct DoFUniform {
focus_distance: f32,
aperture: f32,
max_blur: f32,
near: f32,
far: f32,
inv_width: f32,
inv_height: f32,
_pad: f32,
};
@group(0) @binding(0) var<uniform> u: DoFUniform;
@group(0) @binding(1) var depth_tex: texture_depth_2d;
@group(0) @binding(2) var sampler: sampler;
@fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
let ndc_z = textureSample(depth_tex, sampler, uv);
// Linearize: NDC [0,1] → world distance
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
// CoC in pixels
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
/ max(u.focus_distance, 1e-4);
coc = min(coc, u.max_blur);
// Edge case: depth = 1.0 (far plane) → no blur
if (ndc_z >= 0.9999) { coc = 0.0; }
return coc;
}
let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y);
let bitangent = normalize(cross(n, tangent));
let tbn = mat3x3<f32>(tangent, bitangent, n);
```
#### `dof_blur.wgsl`
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
// Vertex : fullscreen triangle (id)
// 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);
struct DoFUniform { /* idem */ };
// 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));
```
@group(0) @binding(0) var<uniform> u: DoFUniform;
@group(0) @binding(1) var color_tex: texture_2d<f32>;
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
@group(0) @binding(3) var sampler: sampler;
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)
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
vec2(0.0, 0.0),
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(-1.0, 0.0),
vec2(0.707, 0.707), vec2(0.707, -0.707),
vec2(-0.707, 0.707), vec2(-0.707, -0.707),
vec2(0.383, 0.924), vec2(-0.383, 0.924), vec2(0.383, -0.924),
);
### 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_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
let coc = textureSample(coc_tex, sampler, uv).r;
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;
if (coc < 0.5) {
// Below 0.5px: no blur needed
return textureSample(color_tex, sampler, uv);
// 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 radius = coc; // in pixels
var sum = vec4<f32>(0.0);
for (var i = 0u; i < 12u; i++) {
let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height);
sum += textureSample(color_tex, sampler, uv + offset);
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);
}
return sum / 12.0;
// 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);
}
```
### D9 — Bind group layouts
### D8 — Texture normal map : nouveau binding `@group(2) @binding(2)`
**CoC pipeline** (3 bindings) :
| Binding | Type | Description |
|---------|------|-------------|
| 0 | Uniform (32B) | DoF params |
| 1 | Texture (depth) | Depth buffer de la scène |
| 2 | Sampler | Linear, clamp |
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;
```
**Blur pipeline** (4 bindings) :
| Binding | Type | Description |
|---------|------|-------------|
| 0 | Uniform (32B) | DoF params |
| 1 | Texture (color) | HDR/bloom color |
| 2 | Texture (color) | CoC texture |
| 3 | Sampler | Linear, clamp |
- 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
Chaque pipeline a **son propre** pipeline layout (règle wgpu 30).
### D10 — `DoFPipeline` struct
### D9 — `Material::pbr()` constructor
```rust
pub(crate) struct DoFPipeline {
// Textures
coc_texture: Texture,
coc_view: TextureView,
output_texture: Texture,
output_view: TextureView,
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 { ... }
// Sampler (shared between both passes)
sampler: Sampler,
// Pipelines
coc_pipeline: RenderPipeline,
blur_pipeline: RenderPipeline,
// Uniform buffer (shared: same values for both passes)
uniform_buffer: Buffer,
// Bind groups
coc_bind_group: BindGroup,
blur_bind_group: BindGroup,
/// 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 { ... }
}
```
Méthodes :
- `DoFPipeline::new(device, width, height, depth_view, color_view)` → alloue tout
- `DoFPipeline::update_uniform(&mut self, queue, config, near, far)` → écrit le buffer
- `DoFPipeline::output_view(&self) -> &TextureView` → pour re-pointer le TM
- `DoFPipeline::output_texture(&self) -> &Texture` → pour le bind group TM
- `DoFPipeline::resize(...)` → recrée textures + bind groups
### D10 — Rétrocompatibilité
### D11 — Resize
- `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 :
Dans `resize_depth` (ou équivalent) :
```rust
if let Some(dof) = &mut self.dof_pipeline {
dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view);
// É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));
}
```
### D12 — Ordre des post-process
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...
```text
Main pass → HDR
→ Bloom (si actif) → bloom_composite
→ DoF (si actif) → dof_output
→ TM → surface
```
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.
DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel.
### D13 — Example `pbr.rs`
### D13 — Compatibilité
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
| Avec | OK ? | Note |
|------|------|------|
| HDR | ✅ **requis** | DoF opère sur la texture HDR |
| Bloom | ✅ | DoF après bloom (D12) |
| MSAA | ✅ | Après resolve, DoF voit la texture single-sample |
| Fog | ✅ | Fog est dans le main pass, DoF floute le résultat |
| Culling | ✅ | Indépendant |
### D14 — Normal map procédurale pour l'exemple
### D14 — `with_dof` sans `with_hdr` = no-op
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`
Comme bloom, DoF nécessite HDR. `with_dof()` sans `with_hdr()` → warning + no-op.
## É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 |
## Fichiers modifiés / créés
## Risques et mitigations
| Fichier | Action |
|---------|--------|
| `lib/src/core/dof.rs` | **NEW** — `DoFConfig` + `DoFPipeline` |
| `lib/src/core/mod.rs` | + `pub mod dof;` + re-exports |
| `lib/src/lib.rs` | + `pub use DoFConfig` |
| `lib/src/prelude.rs` | + `DoFConfig` |
| `lib/src/core/renderer.rs` | + `dof` field, `set_dof()`, render pass, resize |
| `lib/src/app.rs` | + `with_dof()`, plumbage App/Builder/Runner |
| `lib/src/shaders/dof_coc.wgsl` | **NEW** |
| `lib/src/shaders/dof_blur.wgsl` | **NEW** |
| `lib/tests/wgsl_validate.rs` | + 2 shaders DoF |
| `lib/examples/dof.rs` | **NEW** |
| `lib/examples/README.md` | + section DoF |
| `docs/user/dof.md` | **NEW** |
| `docs/user/README.md` | + ligne DoF |
| `docs/ROADMAP.md` | 6.17 → ✅ |
| 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
## Plan d'implémentation
| # | Tâche | Dépend |
|---|-------|--------|
| 1 | `core/dof.rs` : `DoFConfig` + tests | — |
| 2 | `core/mod.rs` + `lib.rs` + `prelude.rs` : exports | 1 |
| 3 | `shaders/dof_coc.wgsl` + `shaders/dof_blur.wgsl` | — |
| 4 | `tests/wgsl_validate.rs` : ajouter les 2 shaders | 3 |
| 5 | `core/dof.rs` : `DoFPipeline` (textures, pipelines, BGL, bind groups) | 3 |
| 6 | `core/renderer.rs` : fields + `new` + `set_dof` + `render_scene` + `resize` | 5 |
| 7 | `app.rs` : `with_dof()` + plumbage | 6 |
| 8 | `examples/dof.rs` | 6 |
| 9 | Docs : examples README + user docs + ROADMAP | 8 |
| 10 | Vérification : `cargo check` + tests + examples | all |
---
## Estimation
- **Effort** : Moyen (~200 lignes Rust + ~80 lignes WGSL)
- **Risque** : Bas (pattern identique à bloom, 2 passes simples)
- **Gain visuel** : ⭐⭐⭐ (effet cinématique immédiat)
- [ ] `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
+1 -1
View File
@@ -67,7 +67,7 @@ Ce document est la **vue d'ensemble de progression**. Chaque étape a son DRAFT
| 6.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ |
| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ |
| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | Moyen | ✅ |
| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ⬜ |
| 6.5 | **Normal mapping / PBR** (nouveau shader, tangent space, metalness-roughness) | ⭐⭐⭐ | Élevé | ✅ |
| 6.6 | **Cascaded Shadow Maps** (2–3 cascades + blend, plus de précision près de la camera) | ⭐⭐ | Élevé | ⬜ |
| 6.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ |
| 6.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | Faible | ✅ |