From 54a482e354b2db4e977c6c3656386b47c23bcc49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Fri, 25 Sep 2026 14:40:47 +0200 Subject: [PATCH] PBR --- docs/DRAFT.md | 640 ++++++++++++--------------- docs/ROADMAP.md | 2 +- lib/examples/README.md | 20 + lib/examples/pbr.rs | 194 ++++++++ lib/src/core/renderer.rs | 6 + lib/src/pipeline/pipeline_cache.rs | 75 +++- lib/src/resources/material.rs | 64 ++- lib/src/resources/texture.rs | 14 + lib/src/resources/uniform.rs | 7 +- lib/src/scene/scene.rs | 44 ++ lib/src/shaders/standard_shader.wgsl | 166 +++++++ lib/tests/wgsl_validate.rs | 4 +- 12 files changed, 866 insertions(+), 370 deletions(-) create mode 100644 lib/examples/pbr.rs diff --git a/docs/DRAFT.md b/docs/DRAFT.md index d68bc43..3ee5bbb 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -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, // 64 bytes (offset 0) + emissive: vec4, // 16 bytes (offset 64) + pbr: vec4, // 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 { - // 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 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) -> @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(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(1.0), 0.5 * (1.0 - roughness)); +``` -@group(0) @binding(0) var u: DoFUniform; -@group(0) @binding(1) var color_tex: texture_2d; -@group(0) @binding(2) var coc_tex: texture_2d; -@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, 12> = array, 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, h: vec3, 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, v: vec3, l: vec3, roughness: f32) -> f32 { + let a = roughness * roughness; + let kv = vec2(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(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) -> vec3 { + return f0 + (vec3(1.0) - f0) * pow(1.0 - cos_theta, 5.0); +} + +fn brdf_pbr(n: vec3, v: vec3, l: vec3, + base: vec3, metallic: f32, roughness: f32) -> vec3 { + let h = normalize(v + l); + let f0 = mix(vec3(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(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) -> @location(0) vec4 { - 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 { + 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(apply_fog(base + emissive_contrib, in.world_pos), in.color.a); } - let radius = coc; // in pixels - var sum = vec4(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(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(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; +@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>` +- 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>, + normal_map: Option>, + 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` (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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f04af45..c9aaf73 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 | ✅ | diff --git a/lib/examples/README.md b/lib/examples/README.md index d85640b..7bc28a6 100644 --- a/lib/examples/README.md +++ b/lib/examples/README.md @@ -319,3 +319,23 @@ cargo run -p wsg-lib --example import --features import-obj ``` Pas de touches — s'exécute et quitte. + +--- + +## `pbr` — PBR Metallic/Roughness + Normal Mapping (Étape 27) + +Démonstration du workflow PBR Cook-Torrance : GGX distribution + Smith visibility + +Schlick Fresnel + IBL hémisphérique + normal mapping. + +```sh +cargo run -p wsg-lib --example pbr +``` + +| Touche | Action | +|--------|--------| +| Drag (LMB) | Orbite caméra | +| Molette | Zoom | +| `R` | Reset caméra | + +Scène : 6 matériaux PBR (métal miroir, plastique, rouillé, céramique, bump map, sol matte). +Le cube avec normal map montre des bumps procéduraux (sin wave). diff --git a/lib/examples/pbr.rs b/lib/examples/pbr.rs new file mode 100644 index 0000000..7f7b993 --- /dev/null +++ b/lib/examples/pbr.rs @@ -0,0 +1,194 @@ +//! # Exemple PBR — Metallic/Roughness + Normal Mapping (Étape 27) +//! +//! Démonstration du workflow PBR Cook-Torrance (GGX + Smith + Schlick) avec IBL hémisphérique. +//! +//! ## Scène +//! - Sol : plan 20×20, PBR matte (metallic=0, roughness=0.8) +//! - Cube métal : metallic=1.0, roughness=0.1 → reflet spéculaire net (miroir) +//! - Cube plastique : metallic=0.0, roughness=0.4 → spéculaire large et doux +//! - Cube rouillé : metallic=0.8, roughness=0.7 → métal rugueux +//! - Sphere céramique : metallic=0.3, roughness=0.3 +//! - Cube normal map : bump procédural (sin wave) +//! +//! ## Contrôles +//! | Touche | Action | +//! |--------|--------| +//! | Drag (LMB) | Orbite caméra | +//! | Molette | Zoom | +//! | `R` | Reset caméra | +//! +//! ## Lancement +//! ```bash +//! cargo run -p wsg-lib --example pbr +//! ``` + +use glam::Vec3; +use winit::event::MouseButton; +use winit::keyboard::KeyCode; +use wsg_lib::app::AppBuilder; +use wsg_lib::camera::CameraController; +use wsg_lib::core::{ToneMapper, Transform}; +use wsg_lib::mesh::{cube, icosphere, plane}; +use wsg_lib::resources::Texture; +use wsg_lib::AppHandler; +use wsg_lib::utils::WsgError; + +struct PbrDemo { + camera: CameraController, +} + +impl Default for PbrDemo { + fn default() -> Self { + Self { + camera: CameraController::default(), + } + } +} + +impl AppHandler for PbrDemo { + fn setup(&mut self, app: &mut wsg_lib::App) { + app.scene + .register_shader("standard", wsg_lib::utils::STANDARD_SHADER_PATH) + .unwrap(); + + // Normal map procédurale 256×256 : bump sin(x)*sin(y). + let bump_map = make_bump_normal_map(&app.context().device, &app.context().queue); + app.scene.add_texture("bump_nm", bump_map).unwrap(); + + // Matériaux PBR. + app.scene.add_material_pbr("floor", "standard", 0.0, 0.8).unwrap(); + app.scene.add_material_pbr("metal", "standard", 1.0, 0.1).unwrap(); + app.scene.add_material_pbr("plastic", "standard", 0.0, 0.4).unwrap(); + app.scene.add_material_pbr("rust", "standard", 0.8, 0.7).unwrap(); + app.scene.add_material_pbr("ceramic", "standard", 0.3, 0.3).unwrap(); + app.scene + .add_material_pbr_textured("bump", "standard", 0.0, 0.5, None, Some("bump_nm")) + .unwrap(); + + // Sol (plan 20×20). + app.scene + .create_mesh("floor_mesh", plane(1.0, 1.0, 1, 1), Some("floor")) + .unwrap(); + { + let mut tf = Transform::identity(); + tf.translation = Vec3::new(0.0, 0.0, 0.0); + tf.scale = Vec3::new(20.0, 1.0, 20.0); + app.scene.add_entity_with_transform("floor", "floor_mesh", tf).unwrap(); + } + + // Cubes. + app.scene.create_mesh("cube_mesh", cube(1.0), None).unwrap(); + let cubes: [(&str, &str, Vec3); 4] = [ + ("c_metal", "metal", Vec3::new(-3.0, 0.5, 0.0)), + ("c_plastic", "plastic", Vec3::new(-1.0, 0.5, 0.0)), + ("c_rust", "rust", Vec3::new(1.0, 0.5, 0.0)), + ("c_bump", "bump", Vec3::new(3.0, 0.5, 0.0)), + ]; + for (id, mat, pos) in &cubes { + app.scene + .create_mesh(&format!("{id}_mesh"), cube(1.0), Some(mat)) + .unwrap(); + let mut tf = Transform::identity(); + tf.translation = *pos; + app.scene + .add_entity_with_transform(id, &format!("{id}_mesh"), tf) + .unwrap(); + } + + // Sphere céramique. + app.scene + .create_mesh("sphere_mesh", icosphere(0.5, 4), Some("ceramic")) + .unwrap(); + { + let mut tf = Transform::identity(); + tf.translation = Vec3::new(0.0, 0.5, -3.0); + app.scene + .add_entity_with_transform("s_ceramic", "sphere_mesh", tf) + .unwrap(); + } + + // Lumières. + app.scene + .add_directional_light(Vec3::new(-1.0, 2.0, 1.0).normalize(), [1.0, 0.95, 0.9], 2.0) + .unwrap(); + app.scene + .add_point_light(Vec3::new(0.0, 3.0, 2.0), [0.3, 0.5, 1.0], 8.0, 5.0) + .unwrap(); + + + // Ambiance (IBL hémisphérique). + app.scene.set_ambient([0.3, 0.35, 0.4]); + + // Caméra. + self.camera.yaw = 0.0; + self.camera.pitch = 0.3; + self.camera.distance = 8.0; + self.camera.target = Vec3::new(0.0, 0.5, 0.0); + self.camera.apply_to(app.scene.camera_mut()); + + eprintln!("[PBR] Scene: 6 PBR materials (metal/plastic/rust/ceramic/bump/floor)"); + eprintln!("[PBR] Drag=orbit, Wheel=zoom, R=reset"); + } + + fn update(&mut self, app: &mut wsg_lib::App) { + // Orbite caméra. + let (dx, dy) = app.input.mouse_delta(); + if app.input.mouse_button_held(MouseButton::Left) { + self.camera.orbit(dx, dy); + } + let (_, sy) = app.input.scroll_delta(); + self.camera.zoom(sy); + self.camera.apply_to(app.scene.camera_mut()); + + // R = reset. + if app.input.key_pressed(KeyCode::KeyR) { + self.camera = CameraController::default(); + self.camera.target = Vec3::new(0.0, 0.5, 0.0); + self.camera.apply_to(app.scene.camera_mut()); + } + } +} + +/// Génère une normal map procédurale 256×256 : pattern sin(x*freq)*sin(y*freq) → bump. +/// Chaque pixel : normale perturbée encodée en RGB (nx*0.5+0.5, ny*0.5+0.5, nz*0.5+0.5) * 255. +fn make_bump_normal_map(device: &wgpu::Device, queue: &wgpu::Queue) -> Texture { + let size = 256u32; + let freq = 8.0; + let mut pixels: Vec = vec![0u8; (size * size * 4) as usize]; + + for y in 0..size { + for x in 0..size { + let u = x as f32 / size as f32; + let v = y as f32 / size as f32; + let h = (u * freq * std::f32::consts::PI).sin() + * (v * freq * std::f32::consts::PI).sin(); + let eps = 1.0 / size as f32; + let hx = ((u + eps) * freq * std::f32::consts::PI).sin() + * (v * freq * std::f32::consts::PI).sin(); + let hy = (u * freq * std::f32::consts::PI).sin() + * ((v + eps) * freq * std::f32::consts::PI).sin(); + let dhdx = (hx - h) / eps; + let dhdy = (hy - h) / eps; + let n = Vec3::new(-dhdx, -dhdy, 1.0).normalize(); + let idx = ((y * size + x) * 4) as usize; + pixels[idx] = ((n.x * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8; + pixels[idx + 1] = ((n.y * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8; + pixels[idx + 2] = ((n.z * 0.5 + 0.5) * 255.0).clamp(0.0, 255.0) as u8; + pixels[idx + 3] = 255; + } + } + + Texture::from_rgba8(device, queue, size, size, &pixels, "bump_normal_map") + .expect("bump normal map creation failed") +} + +#[pollster::main] +async fn main() -> Result<(), WsgError> { + let app = AppBuilder::new() + .title("WSG — PBR Metallic/Roughness") + .size(1280, 720) + .with_hdr(ToneMapper::Aces) + .build() + .await?; + app.run(PbrDemo::default()) +} diff --git a/lib/src/core/renderer.rs b/lib/src/core/renderer.rs index a885d1a..a3f2bfe 100644 --- a/lib/src/core/renderer.rs +++ b/lib/src/core/renderer.rs @@ -258,6 +258,7 @@ impl Renderer { let identity_object = ObjectUniform { model: glam::Mat4::IDENTITY, emissive: glam::Vec4::ZERO, + pbr: glam::Vec4::ZERO, }; queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object)); let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -1220,6 +1221,7 @@ impl Renderer { } // Emissive (6.2): write per-slot into the matrix buffer padding (bytes 64-79). // The compute pass only overwrites bytes 0-63 (the matrix), so the emissive persists. + // PBR (Étape 27): metallic/roughness at bytes 80-95 (always written for correctness). for slot in scene.iter_slot_draws().filter(|s| s.active) { let mat = slot .mesh @@ -1230,6 +1232,10 @@ impl Renderer { let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64; self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&mat.emissive)); } + // PBR params (metallic, roughness) — always written (buffer init to 0 is wrong for PBR). + 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)); } // 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite). diff --git a/lib/src/pipeline/pipeline_cache.rs b/lib/src/pipeline/pipeline_cache.rs index c1422b4..4e9132a 100644 --- a/lib/src/pipeline/pipeline_cache.rs +++ b/lib/src/pipeline/pipeline_cache.rs @@ -92,6 +92,23 @@ pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGrou }, count: None, }, + // Étape 27 : normal map (binding 2) + son sampler (binding 3). + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }) } @@ -202,6 +219,9 @@ pub struct PipelineCache { /// White 1×1 placeholder texture bound by materials that have no diffuse texture (DRAFT D1/D2). /// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look. placeholder: Arc, + /// Normal map placeholder (128,128,255) = flat normal. Bound when a material has no normal map. + /// Étape 27 : ensures group-2 is always satisfied (4 bindings). + normal_placeholder: Arc, /// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's /// attachment sample count. 1 = no MSAA (default). sample_count: u32, @@ -215,6 +235,7 @@ impl PipelineCache { /// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first. pub fn new(device: Arc, queue: wgpu::Queue, sample_count: u32) -> Self { let placeholder = Texture::white_placeholder(&device, &queue).arc(); + let normal_placeholder = Texture::normal_placeholder(&device, &queue).arc(); let texture_bind_group_layout = create_texture_bind_group_layout(&device); Self { device, @@ -224,6 +245,7 @@ impl PipelineCache { shader_paths: HashMap::new(), texture_bind_group_layout, placeholder, + normal_placeholder, sample_count, } } @@ -246,7 +268,18 @@ impl PipelineCache { /// wgpu directly (Step 10, DRAFT D4). Inputs: texture — the material's diffuse texture, `None` /// for a texture-less material (binds the placeholder). Returns the group-2 bind group. pub fn texture_bind_group(&self, texture: Option>) -> wgpu::BindGroup { + Self::texture_bind_group_full(self, texture, None) + } + + /// Builds a group-2 bind group with both diffuse and normal map textures (Étape 27). + /// `texture` = diffuse (None → white placeholder), `normal_map` = normal map (None → flat placeholder). + pub fn texture_bind_group_full( + &self, + texture: Option>, + normal_map: Option>, + ) -> wgpu::BindGroup { let tex = texture.unwrap_or_else(|| self.placeholder.clone()); + let nmap = normal_map.unwrap_or_else(|| self.normal_placeholder.clone()); self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("texture bind group"), layout: &self.texture_bind_group_layout, @@ -259,6 +292,14 @@ impl PipelineCache { binding: 1, resource: wgpu::BindingResource::TextureView(&tex.view), }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&nmap.view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&nmap.sampler), + }, ], }) } @@ -297,24 +338,43 @@ impl PipelineCache { format: wgpu::TextureFormat, shader_id: &str, ) -> Arc { - // Step 1: Return cached pipeline if it already exists for this shader_id - if let Some(pipeline) = self.pipelines.get(shader_id) { + self.get_or_create_entry(format, shader_id, "fs_main") + } + + /// Étape 27 : creates (or retrieves) a PBR pipeline using the `fs_pbr` entry point. + /// The shader_id is the same WGSL file (standard_shader.wgsl) but with a different fragment entry. + pub fn get_or_create_pbr( + &mut self, + format: wgpu::TextureFormat, + shader_id: &str, + ) -> Arc { + self.get_or_create_entry(format, shader_id, "fs_pbr") + } + + /// Shared pipeline creation: loads the shader and builds a pipeline with the given fragment entry point. + fn get_or_create_entry( + &mut self, + format: wgpu::TextureFormat, + shader_id: &str, + entry_point: &str, + ) -> Arc { + // Cache key includes the entry point to distinguish fs_main from fs_pbr pipelines. + let cache_key = format!("{shader_id}:{entry_point}"); + if let Some(pipeline) = self.pipelines.get(&cache_key) { return pipeline.clone(); } - // Step 2: Compile a new pipeline — loads shader and builds the GPU render pipeline let path = self .shader_paths .get(shader_id) .map(|s| s.as_str()) .unwrap_or(shader_id); let shader = self.load_shader(&self.device, path); - let pipeline = self.build_pipeline(format, &shader); + let pipeline = self.build_pipeline(format, &shader, entry_point); - // Step 3: Cache the new pipeline behind Arc and return it let pipeline_arc = Arc::new(pipeline); self.pipelines - .insert(shader_id.to_string(), pipeline_arc.clone()); + .insert(cache_key, pipeline_arc.clone()); pipeline_arc } @@ -341,6 +401,7 @@ impl PipelineCache { &self, format: wgpu::TextureFormat, shader: &wgpu::ShaderModule, + entry_point: &str, ) -> wgpu::RenderPipeline { // Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs. // Must match Vertex struct field offsets exactly. @@ -380,7 +441,7 @@ impl PipelineCache { }, fragment: Some(wgpu::FragmentState { module: shader, - entry_point: Some("fs_main"), + entry_point: Some(entry_point), compilation_options: Default::default(), // required field in wgpu 30 // targets is now &[Option] — each wrapped in Some. targets: &[Some(wgpu::ColorTargetState { diff --git a/lib/src/resources/material.rs b/lib/src/resources/material.rs index 5ab7c28..bc9ad3b 100644 --- a/lib/src/resources/material.rs +++ b/lib/src/resources/material.rs @@ -27,12 +27,18 @@ pub struct Material { pub pipeline: Arc, /// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2). pub texture: Option>, + /// Étape 27 : normal map texture. `None` → the flat normal placeholder is bound. + pub normal_texture: Option>, /// Group-2 bind group linking the diffuse texture (or the placeholder) and its sampler. Built in /// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`. pub texture_bind_group: wgpu::BindGroup, /// Emissive color (rgb) + intensity (a). Offset 64 in the ObjectUniform. Default `[0,0,0,0]` /// = no emission (non-regression). In HDR, `a > 1.0` creates a glow effect. pub emissive: [f32; 4], + /// Étape 27 : metallic factor [0,1]. 0 = dielectric, 1 = pure metal. Offset 80 in ObjectUniform. + pub metallic: f32, + /// Étape 27 : roughness [0,1]. 0 = mirror, 1 = fully rough. Offset 84 in ObjectUniform. + pub roughness: f32, } impl Material { @@ -42,7 +48,7 @@ impl Material { /// cache (mutable reference for potential insertion of new pipelines). /// Returns a Material holding the Arc-wrapped pipeline. Called at scene initialization time only. pub fn new(format: wgpu::TextureFormat, shader_id: &str, cache: &mut PipelineCache) -> Self { - Self::build(format, shader_id, None, cache) + Self::build(format, shader_id, None, None, cache) } /// Creates a Material with a diffuse texture: compiles/retrieves the pipeline and builds a @@ -54,7 +60,55 @@ impl Material { texture: Arc, cache: &mut PipelineCache, ) -> Self { - Self::build(format, shader_id, Some(texture), cache) + Self::build(format, shader_id, Some(texture), None, cache) + } + + /// Étape 27 : crée un matériau PBR (Cook-Torrance metallic/roughness + normal mapping). + /// Le shader_id doit être un shader contenant l'entry point `fs_pbr`. + /// Par défaut : metallic=0, roughness=0.5, pas de normal map. + pub fn pbr( + format: wgpu::TextureFormat, + shader_id: &str, + metallic: f32, + roughness: f32, + cache: &mut PipelineCache, + ) -> Self { + let pipeline = cache.get_or_create_pbr(format, shader_id); + let texture_bind_group = cache.texture_bind_group_full(None, None); + Self { + shader_id: shader_id.to_string(), + pipeline, + texture: None, + normal_texture: None, + texture_bind_group, + emissive: [0.0, 0.0, 0.0, 0.0], + metallic, + roughness, + } + } + + /// Étape 27 : PBR avec texture albedo et/ou normal map. + pub fn pbr_textured( + format: wgpu::TextureFormat, + shader_id: &str, + metallic: f32, + roughness: f32, + albedo: Option>, + normal_map: Option>, + cache: &mut PipelineCache, + ) -> Self { + let pipeline = cache.get_or_create_pbr(format, shader_id); + let texture_bind_group = cache.texture_bind_group_full(albedo.clone(), normal_map.clone()); + Self { + shader_id: shader_id.to_string(), + pipeline, + texture: albedo, + normal_texture: normal_map, + texture_bind_group, + emissive: [0.0, 0.0, 0.0, 0.0], + metallic, + roughness, + } } /// Shared construction: requests the pipeline from the cache, then builds the group-2 texture @@ -64,16 +118,20 @@ impl Material { format: wgpu::TextureFormat, shader_id: &str, texture: Option>, + normal_texture: Option>, cache: &mut PipelineCache, ) -> Self { let pipeline = cache.get_or_create(format, shader_id); - let texture_bind_group = cache.texture_bind_group(texture.clone()); + let texture_bind_group = cache.texture_bind_group_full(texture.clone(), normal_texture.clone()); Self { shader_id: shader_id.to_string(), pipeline, texture, + normal_texture, texture_bind_group, emissive: [0.0, 0.0, 0.0, 0.0], + metallic: 0.0, + roughness: 0.5, } } } diff --git a/lib/src/resources/texture.rs b/lib/src/resources/texture.rs index 5c62708..e4e4b29 100644 --- a/lib/src/resources/texture.rs +++ b/lib/src/resources/texture.rs @@ -153,6 +153,20 @@ impl Texture { .expect("1×1 white placeholder must not be empty") } + /// Étape 27 : normal map placeholder (128,128,255) = flat normal pointing up in tangent space. + /// Bound by materials without a normal map → `nmap = (0,0,1)` → no perturbation. + pub fn normal_placeholder(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + Self::from_rgba8( + device, + queue, + 1, + 1, + &[128, 128, 255, 255], + "default normal map placeholder", + ) + .expect("1×1 normal placeholder must not be empty") + } + /// Shared convenience wrapper so `Arc` can be created ergonomically by callers. pub(crate) fn arc(self) -> Arc { Arc::new(self) diff --git a/lib/src/resources/uniform.rs b/lib/src/resources/uniform.rs index 394fc5e..56c8bcf 100644 --- a/lib/src/resources/uniform.rs +++ b/lib/src/resources/uniform.rs @@ -150,6 +150,8 @@ pub struct ObjectUniform { pub model: Mat4, /// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression). pub emissive: Vec4, + /// PBR params (Étape 27): metallic, roughness, _pad, _pad. Offset 80. + pub pbr: Vec4, } /// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's @@ -513,11 +515,12 @@ mod tests { #[test] fn object_uniform_layout_matches_wgsl() { - // Étape 22: ObjectUniform is now 80 bytes (64 matrix + 16 emissive). - assert_eq!(size_of::(), 80); + // Étape 27: ObjectUniform is now 96 bytes (64 matrix + 16 emissive + 16 pbr). + assert_eq!(size_of::(), 96); assert_eq!(align_of::(), 16); assert_eq!(offset_of!(ObjectUniform, model), 0); assert_eq!(offset_of!(ObjectUniform, emissive), 64); + assert_eq!(offset_of!(ObjectUniform, pbr), 80); } #[test] diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 9b70000..a808f10 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -200,6 +200,50 @@ impl Scene { Ok(id.to_string()) } + /// Étape 27 : crée un matériau PBR (Cook-Torrance metallic/roughness) et l'enregistre. + /// Le shader_id doit référencer un shader contenant l'entry point `fs_pbr`. + pub fn add_material_pbr( + &mut self, + id: &str, + shader_id: &str, + metallic: f32, + roughness: f32, + ) -> Result { + if self.materials.contains_key(id) { + return Err(format!("Material ID '{}' already exists.", id)); + } + let mut cache = self.gpu().cache.borrow_mut(); + let material = Arc::new(Material::pbr(self.gpu().format, shader_id, metallic, roughness, &mut cache)); + drop(cache); + self.materials.insert(id.to_string(), material); + Ok(id.to_string()) + } + + /// Étape 27 : PBR avec texture albedo et/ou normal map (doivent être enregistrées via add_texture). + pub fn add_material_pbr_textured( + &mut self, + id: &str, + shader_id: &str, + metallic: f32, + roughness: f32, + albedo_id: Option<&str>, + normal_map_id: Option<&str>, + ) -> Result { + if self.materials.contains_key(id) { + return Err(format!("Material ID '{}' already exists.", id)); + } + let albedo = albedo_id.and_then(|tid| self.textures.get(tid).cloned()); + let normal_map = normal_map_id.and_then(|tid| self.textures.get(tid).cloned()); + let mut cache = self.gpu().cache.borrow_mut(); + let material = Arc::new(Material::pbr_textured( + self.gpu().format, shader_id, metallic, roughness, + albedo, normal_map, &mut cache, + )); + drop(cache); + self.materials.insert(id.to_string(), material); + Ok(id.to_string()) + } + /// Registers a diffuse texture in the Scene's resource depot under a unique identifier, so /// materials can reference it declaratively (Step 10, D4). The texture is wrapped in `Arc` for /// zero-copy sharing across materials. Returns Ok(id) or Err(String) if the id already exists. diff --git a/lib/src/shaders/standard_shader.wgsl b/lib/src/shaders/standard_shader.wgsl index c5e2dcc..4d3bddd 100644 --- a/lib/src/shaders/standard_shader.wgsl +++ b/lib/src/shaders/standard_shader.wgsl @@ -100,6 +100,7 @@ struct FrameUniforms { struct ObjectUniform { model: mat4x4, // 64 bytes (offset 0) emissive: vec4, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR) + pbr: vec4, // 16 bytes (offset 80): .x=metallic .y=roughness (Étape 27) }; @group(0) @binding(0) var frame: FrameUniforms; @@ -108,6 +109,9 @@ struct ObjectUniform { // 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; +// É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; +@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; @@ -119,6 +123,7 @@ struct VertexOutput { @location(1) normal: vec3, @location(2) uv: vec2, @location(3) color: vec4, + @location(4) tangent: vec3, // Étape 27 : tangente pour normal mapping }; @vertex @@ -139,6 +144,15 @@ fn vs_main(input: VertexInput) -> VertexOutput { 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(0.0, 1.0, 0.0), + vec3(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; } @@ -291,3 +305,155 @@ fn compute_shadow(world_pos: vec3, normal: vec3) -> f32 { } 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) -> vec3 { + return f0 + (vec3(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, v: vec3, l: vec3, + base: vec3, metallic: f32, roughness: f32) -> vec3 { + let h = normalize(v + l); + let f0 = mix(vec3(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(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, base: vec3, metallic: f32, roughness: f32) -> vec3 { + 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(0.04), base, metallic); + let f = fresnel_schlick(0.0, f0); + let kd = (vec3(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(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 { + let n = normalize(in.normal); + let t = normalize(in.tangent); + let b = normalize(cross(n, t)); + let tbn = mat3x3(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 { + 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(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(apply_fog(final_rgb, in.world_pos), in.color.a); +} diff --git a/lib/tests/wgsl_validate.rs b/lib/tests/wgsl_validate.rs index 0e9b6d3..6aac9f0 100644 --- a/lib/tests/wgsl_validate.rs +++ b/lib/tests/wgsl_validate.rs @@ -25,8 +25,8 @@ fn standard_shader_is_valid_wgsl() { .validate(&module) .unwrap_or_else(|e| panic!("standard_shader.wgsl: validation failed: {e:?}")); - // Contract: exactly the two expected entry points vs_main / fs_main. - assert!(module.entry_points.len() >= 2, "vs_main + fs_main expected"); + // Contract: at least vs_main + fs_main (+ fs_pbr since Étape 27). + assert!(module.entry_points.len() >= 3, "vs_main + fs_main + fs_pbr expected"); } /// Parses and fully validates the embedded `shadow_shader.wgsl` shader (Step 14, D4) via naga.