PBR
This commit is contained in:
+285
-355
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user