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 ## Contexte
> de focus sont flous, créant un effet cinématique. Opt-in via `with_dof()`,
> zéro coût quand désactivé.
--- 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 ## Objectif
objets à la distance de focus sont nets, le reste est flou. Utilité :
- **Effet cinématique** — mettre en scène un objet/personnage Remplacer le modèle Lambert par un **PBR Metalness/Roughness** complet :
- **Guidage du regard** — diriger l'attention du joueur - BRDF Cook-Torrance (GGX distribution + Smith visibility + Schlick Fresnel)
- **Masquage subtil** — flou les zones non pertinentes (alternative douce au fog) - Workflow Metallic/Roughness (industriel : Unreal, Unity, Blender)
- Normal mapping (tangent space, tangente dérivée — pas d'attribut tangent)
### Pipeline existant (avec HDR) - IBL analytique (hémisphère ciel/sol, pas de cubemap)
- **Rétrocompatibilité** : les matériaux existants (metallic=0, roughness=0.5)
```text rendent à peu près comme avant (diffuse + léger spéculaire)
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).
---
## Décisions ## Décisions
### D1 — 2 passes : CoC + Blur ### D1 — Workflow Metallic/Roughness
| Pass | Entrées | Sortie | Format | ```
|------|---------|--------|--------| F0 = mix(vec3(0.04), base_color, metallic) // diélectrique: 4% reflexion, métal: albedo
| CoC | depth texture | coc_texture | `R16Float` (1 canal, radius en pixels) | R = roughness² (GGX alpha)
| 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);
``` ```
- `focus_distance` : distance (unités monde) où l'image est parfaitement nette - `metallic ∈ [0, 1]` : 0 = diélectrique (dielectric), 1 = métal pur
- `aperture` : 0.0–1.0, contrôle l'intensité du flou (0 = pas de flou) - `roughness ∈ [0, 1]` : 0 = miroir, 1 = totalement rugueux
- `max_blur` : radius maximum en pixels (clamp, évite le flou excessif) - 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 Dans le **padding de `ObjectUniform`** (offset 80-87, juste après `emissive` à 64-79) :
struct DoFUniform {
focus_distance: f32, // world units ```rust
aperture: f32, // 0.0-1.0 // WGSL:
max_blur: f32, // pixels struct ObjectUniform {
near: f32, // camera near plane model: mat4x4<f32>, // 64 bytes (offset 0)
far: f32, // camera far plane emissive: vec4<f32>, // 16 bytes (offset 64)
inv_width: f32, // 1.0 / texture width pbr: vec4<f32>, // 16 bytes (offset 80): (metallic, roughness, 0, 0)
inv_height: f32, // 1.0 / texture height // ... padding jusqu'à 256 bytes
_pad: f32,
}; };
``` ```
Un seul uniform partagé entre les 2 passes (CoC et Blur) — les valeurs sont - **Aucune modification du compute shader** (il n'écrit que bytes 0-63)
identiques. Pas de ping-pong de buffers. - 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), Le PBR est **plus complexe** que le Lambert actuel. Plutôt que de modifier
scallé par le CoC local : `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). La sélection est **compile-time** via le `shader_id` :
Le radius variable (par pixel) donne un bokeh naturel. - `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** Le vertex shader est **partagé** entre les deux (même `vs_main`).
> (bokeh), pas un flou directionnel. Un disc blur single-pass est plus fidèle.
> 12 taps × 1 texture = trivial GPU cost.
### D5 — Textures ### D4 — Normal mapping par tangente dérivée
| Texture | Format | Taille | Quand allouée | **Pas d'attribut tangent** dans le vertex buffer (casserait tous les meshes existants).
|---------|--------|--------|---------------| On utilise la méthode des **dérivées ecran-space** (mipmapped derivative tangent) :
| `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`
```wgsl ```wgsl
// Vertex : fullscreen triangle (identique à TM/bloom) // Dans le fragment shader :
@vertex let dpdx = dFdx(world_pos);
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> { let dpdy = dFdy(world_pos);
// même triangle que TM : (-1,-1), (3,-1), (-1,3) let dwdx = dFdx(uv);
} let dwdy = dFdy(uv);
struct DoFUniform { let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y);
focus_distance: f32, let bitangent = normalize(cross(n, tangent));
aperture: f32, let tbn = mat3x3<f32>(tangent, bitangent, n);
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;
}
``` ```
#### `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 ```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; C'est une approximation grossière mais suffisante pour :
@group(0) @binding(1) var color_tex: texture_2d<f32>; - Donner du "remplissage" aux zones non éclairées par les lumières ponctuelles
@group(0) @binding(2) var coc_tex: texture_2d<f32>; - Faire varier le spéculaire IBL selon la roughness (mirroir = brillant, rugueux = mat)
@group(0) @binding(3) var sampler: sampler;
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>( ### D6 — BRDF Cook-Torrance (GGX)
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),
);
```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 @fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> { fn fs_pbr(in: VertexOutput) -> @location(0) vec4<f32> {
let uv = pos.xy * vec2(u.inv_width, u.inv_height); let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let coc = textureSample(coc_tex, sampler, uv).r; let base = texel.rgb * in.color.rgb;
if (coc < 0.5) { // Unlit mode (même que fs_main)
// Below 0.5px: no blur needed if (frame.options.x != 0u) {
return textureSample(color_tex, sampler, uv); 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 let metallic = object.pbr.x;
var sum = vec4<f32>(0.0); let roughness = clamp(object.pbr.y, 0.045, 1.0); // min 0.045 (évite division par 0)
for (var i = 0u; i < 12u; i++) {
let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height); // Normal mapping (derivative tangent)
sum += textureSample(color_tex, sampler, uv + offset); 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) : Le `@group(2)` actuel a 2 bindings (sampler + diffuse texture). On ajoute :
| Binding | Type | Description | ```
|---------|------|-------------| @group(2) @binding(2) var normal_texture: texture_2d<f32>;
| 0 | Uniform (32B) | DoF params | @group(2) @binding(3) var normal_sampler: sampler;
| 1 | Texture (depth) | Depth buffer de la scène | ```
| 2 | Sampler | Linear, clamp |
**Blur pipeline** (4 bindings) : - Sans normal map → placeholder (128,128,255) = normale neutre → aucun effet
| Binding | Type | Description | - Le `Material` gagne un champ `normal_texture: Option<Arc<Texture>>`
|---------|------|-------------| - Le bind group group-2 est reconstruit avec la normal map (ou le placeholder)
| 0 | Uniform (32B) | DoF params | - **Le pipeline layout est le même** pour `fs_main` et `fs_pbr` (mêmes bindings)
| 1 | Texture (color) | HDR/bloom color | → la PipelineCache peut partager le layout
| 2 | Texture (color) | CoC texture |
| 3 | Sampler | Linear, clamp |
Chaque pipeline a **son propre** pipeline layout (règle wgpu 30). ### D9 — `Material::pbr()` constructor
### D10 — `DoFPipeline` struct
```rust ```rust
pub(crate) struct DoFPipeline { impl Material {
// Textures /// Crée un matériau PBR avec metallic/roughness.
coc_texture: Texture, pub fn pbr(
coc_view: TextureView, format: wgpu::TextureFormat,
output_texture: Texture, shader_id: &str, // "pbr"
output_view: TextureView, metallic: f32,
roughness: f32,
cache: &mut PipelineCache,
) -> Self { ... }
// Sampler (shared between both passes) /// Avec texture albedo + normal map.
sampler: Sampler, pub fn pbr_textured(
format: wgpu::TextureFormat,
// Pipelines shader_id: &str,
coc_pipeline: RenderPipeline, metallic: f32,
blur_pipeline: RenderPipeline, roughness: f32,
albedo: Option<Arc<Texture>>,
// Uniform buffer (shared: same values for both passes) normal_map: Option<Arc<Texture>>,
uniform_buffer: Buffer, cache: &mut PipelineCache,
) -> Self { ... }
// Bind groups
coc_bind_group: BindGroup,
blur_bind_group: BindGroup,
} }
``` ```
Méthodes : ### D10 — Rétrocompatibilité
- `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
### 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 ```rust
if let Some(dof) = &mut self.dof_pipeline { // Étape 27 : PBR params (metallic, roughness) dans le padding de ObjectUniform.
dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view); 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 Hmm, roughness=0.0 est un problème (GGX avec alpha=0 → division par zéro).
Main pass → HDR **Solution** : clamer `roughness = max(roughness, 0.045)` dans le shader (déjà prévu en D7).
→ Bloom (si actif) → bloom_composite Le buffer initialisé à 0 → roughness=0 → clampé à 0.045 dans le shader → OK.
→ DoF (si actif) → dof_output
→ TM → surface
```
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 | ### D14 — Normal map procédurale pour l'exemple
|------|------|------|
| 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 — `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 | | Risque | Mitigation |
|---------|--------| |--------|-----------|
| `lib/src/core/dof.rs` | **NEW** — `DoFConfig` + `DoFPipeline` | | GGX avec roughness≈0 → NaN | Clamp `roughness ≥ 0.045` dans le shader |
| `lib/src/core/mod.rs` | + `pub mod dof;` + re-exports | | Dérivées ecran-space instables sur UV dégénérés (poles, seams) | Acceptable pour v1 ; tangent explicite en v2 |
| `lib/src/lib.rs` | + `pub use DoFConfig` | | Le PBR est "trop sombre" vs Lambert | Le `base/π` dans le diffuse PBR assombrit ; compenser par lumière plus intense ou exposure |
| `lib/src/prelude.rs` | + `DoFConfig` | | Normal map placeholder (128,128,255) → artefacts sur certains angles | Le mat3 TBN est orthonormalisé par `normalize` ; acceptable |
| `lib/src/core/renderer.rs` | + `dof` field, `set_dof()`, render pass, resize | | 2 pipelines (fs_main + fs_pbr) → mémoire GPU | ~2 pipelines × ~50KB = négligeable |
| `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 → ✅ |
--- ## Critères d'acceptation
## Plan d'implémentation - [ ] `Material::pbr(format, "pbr", metallic, roughness, cache)` compile et rend
- [ ] Un cube metallic=1, roughness=0.1 a un reflet spéculaire net (miroir)
| # | Tâche | Dépend | - [ ] Un cube metallic=0, roughness=0.9 a un spéculaire large et diffus (mat)
|---|-------|--------| - [ ] Un cube avec normal map montre des bumps visibles
| 1 | `core/dof.rs` : `DoFConfig` + tests | — | - [ ] Les examples existants (demo, bloom, fog, dof) sont **inchangés** (fs_main)
| 2 | `core/mod.rs` + `lib.rs` + `prelude.rs` : exports | 1 | - [ ] `cargo test --workspace` : 0 failures
| 3 | `shaders/dof_coc.wgsl` + `shaders/dof_blur.wgsl` | — | - [ ] `cargo check -p wsg-lib --all-targets` : 0 warnings
| 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)
+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.2 | **Emissive materials** (champ `emissive` → bénéficie du HDR) | ⭐⭐⭐ | Faible | ✅ |
| 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ | | 6.3 | **Bloom** (post-process : downsample → threshold → blur → composite) | ⭐⭐⭐ | Moyen | ✅ |
| 6.4 | **MSAA 4×** (anti-aliasing multi-échantillons + resolve) | ⭐⭐⭐ | 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.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.7 | **SSAO** (ambient occlusion screen-space, depth + normal buffer) | ⭐⭐ | Élevé | ⬜ |
| 6.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | Faible | ✅ | | 6.13 | **Fog** (exponential / exponential² / linear, paramètre par scène) | ⭐⭐⭐ | Faible | ✅ |
+20
View File
@@ -319,3 +319,23 @@ cargo run -p wsg-lib --example import --features import-obj
``` ```
Pas de touches — s'exécute et quitte. 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).
+194
View File
@@ -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<u8> = 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())
}
+6
View File
@@ -258,6 +258,7 @@ impl Renderer {
let identity_object = ObjectUniform { let identity_object = ObjectUniform {
model: glam::Mat4::IDENTITY, model: glam::Mat4::IDENTITY,
emissive: glam::Vec4::ZERO, emissive: glam::Vec4::ZERO,
pbr: glam::Vec4::ZERO,
}; };
queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object)); queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object));
let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 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). // 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. // 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) { for slot in scene.iter_slot_draws().filter(|s| s.active) {
let mat = slot let mat = slot
.mesh .mesh
@@ -1230,6 +1232,10 @@ impl Renderer {
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 64) as u64; 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)); 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). // 8c. Étape 23: bloom passes (threshold → blur H → blur V → composite).
+68 -7
View File
@@ -92,6 +92,23 @@ pub fn create_texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGrou
}, },
count: None, 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). /// 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. /// A white texel is the multiplicative identity, so sampling it reproduces the pre-Step-10 look.
placeholder: Arc<Texture>, placeholder: Arc<Texture>,
/// 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<Texture>,
/// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's /// MSAA sample count for pipeline compilation (Étape 24). Must match the render pass's
/// attachment sample count. 1 = no MSAA (default). /// attachment sample count. 1 = no MSAA (default).
sample_count: u32, 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. /// Called at application startup before any Material creation. Shader paths must be registered via register_shader() first.
pub fn new(device: Arc<wgpu::Device>, queue: wgpu::Queue, sample_count: u32) -> Self { pub fn new(device: Arc<wgpu::Device>, queue: wgpu::Queue, sample_count: u32) -> Self {
let placeholder = Texture::white_placeholder(&device, &queue).arc(); 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); let texture_bind_group_layout = create_texture_bind_group_layout(&device);
Self { Self {
device, device,
@@ -224,6 +245,7 @@ impl PipelineCache {
shader_paths: HashMap::new(), shader_paths: HashMap::new(),
texture_bind_group_layout, texture_bind_group_layout,
placeholder, placeholder,
normal_placeholder,
sample_count, sample_count,
} }
} }
@@ -246,7 +268,18 @@ impl PipelineCache {
/// wgpu directly (Step 10, DRAFT D4). Inputs: texture — the material's diffuse texture, `None` /// 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. /// for a texture-less material (binds the placeholder). Returns the group-2 bind group.
pub fn texture_bind_group(&self, texture: Option<Arc<Texture>>) -> wgpu::BindGroup { pub fn texture_bind_group(&self, texture: Option<Arc<Texture>>) -> 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<Arc<Texture>>,
normal_map: Option<Arc<Texture>>,
) -> wgpu::BindGroup {
let tex = texture.unwrap_or_else(|| self.placeholder.clone()); 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 { self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("texture bind group"), label: Some("texture bind group"),
layout: &self.texture_bind_group_layout, layout: &self.texture_bind_group_layout,
@@ -259,6 +292,14 @@ impl PipelineCache {
binding: 1, binding: 1,
resource: wgpu::BindingResource::TextureView(&tex.view), 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, format: wgpu::TextureFormat,
shader_id: &str, shader_id: &str,
) -> Arc<wgpu::RenderPipeline> { ) -> Arc<wgpu::RenderPipeline> {
// Step 1: Return cached pipeline if it already exists for this shader_id self.get_or_create_entry(format, shader_id, "fs_main")
if let Some(pipeline) = self.pipelines.get(shader_id) { }
/// É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<wgpu::RenderPipeline> {
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<wgpu::RenderPipeline> {
// 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(); return pipeline.clone();
} }
// Step 2: Compile a new pipeline — loads shader and builds the GPU render pipeline
let path = self let path = self
.shader_paths .shader_paths
.get(shader_id) .get(shader_id)
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or(shader_id); .unwrap_or(shader_id);
let shader = self.load_shader(&self.device, path); 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); let pipeline_arc = Arc::new(pipeline);
self.pipelines self.pipelines
.insert(shader_id.to_string(), pipeline_arc.clone()); .insert(cache_key, pipeline_arc.clone());
pipeline_arc pipeline_arc
} }
@@ -341,6 +401,7 @@ impl PipelineCache {
&self, &self,
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
shader: &wgpu::ShaderModule, shader: &wgpu::ShaderModule,
entry_point: &str,
) -> wgpu::RenderPipeline { ) -> wgpu::RenderPipeline {
// Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs. // Define vertex attribute layout — the contract between CPU vertex data and GPU shader inputs.
// Must match Vertex struct field offsets exactly. // Must match Vertex struct field offsets exactly.
@@ -380,7 +441,7 @@ impl PipelineCache {
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
module: shader, module: shader,
entry_point: Some("fs_main"), entry_point: Some(entry_point),
compilation_options: Default::default(), // required field in wgpu 30 compilation_options: Default::default(), // required field in wgpu 30
// targets is now &[Option<ColorTargetState>] — each wrapped in Some. // targets is now &[Option<ColorTargetState>] — each wrapped in Some.
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
+61 -3
View File
@@ -27,12 +27,18 @@ pub struct Material {
pub pipeline: Arc<wgpu::RenderPipeline>, pub pipeline: Arc<wgpu::RenderPipeline>,
/// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2). /// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2).
pub texture: Option<Arc<Texture>>, pub texture: Option<Arc<Texture>>,
/// Étape 27 : normal map texture. `None` → the flat normal placeholder is bound.
pub normal_texture: Option<Arc<Texture>>,
/// Group-2 bind group linking the diffuse texture (or the placeholder) and its sampler. Built in /// 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)`. /// the constructor from the shared layout (DRAFT D4) → bound by `draw_entity` at `@group(2)`.
pub texture_bind_group: wgpu::BindGroup, pub texture_bind_group: wgpu::BindGroup,
/// Emissive color (rgb) + intensity (a). Offset 64 in the ObjectUniform. Default `[0,0,0,0]` /// 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. /// = no emission (non-regression). In HDR, `a > 1.0` creates a glow effect.
pub emissive: [f32; 4], 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 { impl Material {
@@ -42,7 +48,7 @@ impl Material {
/// cache (mutable reference for potential insertion of new pipelines). /// cache (mutable reference for potential insertion of new pipelines).
/// Returns a Material holding the Arc-wrapped pipeline. Called at scene initialization time only. /// 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 { 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 /// Creates a Material with a diffuse texture: compiles/retrieves the pipeline and builds a
@@ -54,7 +60,55 @@ impl Material {
texture: Arc<Texture>, texture: Arc<Texture>,
cache: &mut PipelineCache, cache: &mut PipelineCache,
) -> Self { ) -> 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<Arc<Texture>>,
normal_map: Option<Arc<Texture>>,
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 /// Shared construction: requests the pipeline from the cache, then builds the group-2 texture
@@ -64,16 +118,20 @@ impl Material {
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
shader_id: &str, shader_id: &str,
texture: Option<Arc<Texture>>, texture: Option<Arc<Texture>>,
normal_texture: Option<Arc<Texture>>,
cache: &mut PipelineCache, cache: &mut PipelineCache,
) -> Self { ) -> Self {
let pipeline = cache.get_or_create(format, shader_id); 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 { Self {
shader_id: shader_id.to_string(), shader_id: shader_id.to_string(),
pipeline, pipeline,
texture, texture,
normal_texture,
texture_bind_group, texture_bind_group,
emissive: [0.0, 0.0, 0.0, 0.0], emissive: [0.0, 0.0, 0.0, 0.0],
metallic: 0.0,
roughness: 0.5,
} }
} }
} }
+14
View File
@@ -153,6 +153,20 @@ impl Texture {
.expect("1×1 white placeholder must not be empty") .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<Texture>` can be created ergonomically by callers. /// Shared convenience wrapper so `Arc<Texture>` can be created ergonomically by callers.
pub(crate) fn arc(self) -> Arc<Texture> { pub(crate) fn arc(self) -> Arc<Texture> {
Arc::new(self) Arc::new(self)
+5 -2
View File
@@ -150,6 +150,8 @@ pub struct ObjectUniform {
pub model: Mat4, pub model: Mat4,
/// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression). /// Emissive color (rgb) + intensity (a). Offset 64. Zero = no emission (non-regression).
pub emissive: Vec4, 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 /// GPU uniforms of the depth-only shadow pass (Step 14, D4): the shadow-casting light's
@@ -513,11 +515,12 @@ mod tests {
#[test] #[test]
fn object_uniform_layout_matches_wgsl() { fn object_uniform_layout_matches_wgsl() {
// Étape 22: ObjectUniform is now 80 bytes (64 matrix + 16 emissive). // Étape 27: ObjectUniform is now 96 bytes (64 matrix + 16 emissive + 16 pbr).
assert_eq!(size_of::<ObjectUniform>(), 80); assert_eq!(size_of::<ObjectUniform>(), 96);
assert_eq!(align_of::<ObjectUniform>(), 16); assert_eq!(align_of::<ObjectUniform>(), 16);
assert_eq!(offset_of!(ObjectUniform, model), 0); assert_eq!(offset_of!(ObjectUniform, model), 0);
assert_eq!(offset_of!(ObjectUniform, emissive), 64); assert_eq!(offset_of!(ObjectUniform, emissive), 64);
assert_eq!(offset_of!(ObjectUniform, pbr), 80);
} }
#[test] #[test]
+44
View File
@@ -200,6 +200,50 @@ impl Scene {
Ok(id.to_string()) 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<String, String> {
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<String, String> {
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 /// 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 /// 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. /// zero-copy sharing across materials. Returns Ok(id) or Err(String) if the id already exists.
+166
View File
@@ -100,6 +100,7 @@ struct FrameUniforms {
struct ObjectUniform { struct ObjectUniform {
model: mat4x4<f32>, // 64 bytes (offset 0) model: mat4x4<f32>, // 64 bytes (offset 0)
emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR) emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR)
pbr: vec4<f32>, // 16 bytes (offset 80): .x=metallic .y=roughness (Étape 27)
}; };
@group(0) @binding(0) var<uniform> frame: FrameUniforms; @group(0) @binding(0) var<uniform> frame: FrameUniforms;
@@ -108,6 +109,9 @@ struct ObjectUniform {
// texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel. // texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel.
@group(2) @binding(0) var texture_sampler: sampler; @group(2) @binding(0) var texture_sampler: sampler;
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>; @group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
// É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<f32>;
@group(2) @binding(3) var normal_sampler: sampler;
// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1). // É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). // Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées).
@group(3) @binding(0) var shadow_sampler: sampler_comparison; @group(3) @binding(0) var shadow_sampler: sampler_comparison;
@@ -119,6 +123,7 @@ struct VertexOutput {
@location(1) normal: vec3<f32>, @location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>, @location(3) color: vec4<f32>,
@location(4) tangent: vec3<f32>, // Étape 27 : tangente pour normal mapping
}; };
@vertex @vertex
@@ -139,6 +144,15 @@ fn vs_main(input: VertexInput) -> VertexOutput {
out.normal = normal_matrix * input.normal; out.normal = normal_matrix * input.normal;
out.uv = input.uv; out.uv = input.uv;
out.color = input.color; 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<f32>(0.0, 1.0, 0.0),
vec3<f32>(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; return out;
} }
@@ -291,3 +305,155 @@ fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> f32 {
} }
return lit_count / 9.0; 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<f32>) -> vec3<f32> {
return f0 + (vec3<f32>(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<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 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<f32>(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<f32>, base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
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<f32>(0.04), base, metallic);
let f = fresnel_schlick(0.0, f0);
let kd = (vec3<f32>(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<f32>(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<f32> {
let n = normalize(in.normal);
let t = normalize(in.tangent);
let b = normalize(cross(n, t));
let tbn = mat3x3<f32>(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<f32> {
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<f32>(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<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
+2 -2
View File
@@ -25,8 +25,8 @@ fn standard_shader_is_valid_wgsl() {
.validate(&module) .validate(&module)
.unwrap_or_else(|e| panic!("standard_shader.wgsl: validation failed: {e:?}")); .unwrap_or_else(|e| panic!("standard_shader.wgsl: validation failed: {e:?}"));
// Contract: exactly the two expected entry points vs_main / fs_main. // Contract: at least vs_main + fs_main (+ fs_pbr since Étape 27).
assert!(module.entry_points.len() >= 2, "vs_main + fs_main expected"); 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. /// Parses and fully validates the embedded `shadow_shader.wgsl` shader (Step 14, D4) via naga.