PBR
This commit is contained in:
+285
-355
@@ -1,424 +1,354 @@
|
||||
# Étape 26 — Depth of Field (DoF)
|
||||
# DRAFT — Étape 27 : PBR Metallic/Roughness + Normal Mapping (Phase 6.5)
|
||||
|
||||
> **Objectif** : Flou de profondeur post-process — les objets hors de la distance
|
||||
> de focus sont flous, créant un effet cinématique. Opt-in via `with_dof()`,
|
||||
> zéro coût quand désactivé.
|
||||
## Contexte
|
||||
|
||||
---
|
||||
Le shader actuel (`standard_shader.wgsl`) utilise un modèle d'éclairage simpliste :
|
||||
- Diffuse Lambert (`N·L`) + ambient hémisphérique
|
||||
- **Aucun terme spéculaire** (pas de Blinn-Phong, pas de Cook-Torrance)
|
||||
- Pas de normal mapping
|
||||
- Pas d'IBL (Image-Based Lighting)
|
||||
|
||||
## Contexte & motivation
|
||||
Résultat : les matériaux métalliques ne brillent pas, les surfaces rugueuses ne
|
||||
s'assombrissent pas correctement, et les normales ne peuvent pas être sculptées
|
||||
via texture. Le saut vers PBR est le plus grand gain visuel restant.
|
||||
|
||||
Le DoF (Depth of Field) simule le comportement d'un objectif photo : seuls les
|
||||
objets à la distance de focus sont nets, le reste est flou. Utilité :
|
||||
## Objectif
|
||||
|
||||
- **Effet cinématique** — mettre en scène un objet/personnage
|
||||
- **Guidage du regard** — diriger l'attention du joueur
|
||||
- **Masquage subtil** — flou les zones non pertinentes (alternative douce au fog)
|
||||
|
||||
### Pipeline existant (avec HDR)
|
||||
|
||||
```text
|
||||
Main pass → HDR texture (Rgba16Float)
|
||||
↓
|
||||
Bloom (si actif) → composite
|
||||
↓
|
||||
Tone Mapping → surface
|
||||
```
|
||||
|
||||
### Pipeline avec DoF
|
||||
|
||||
```text
|
||||
Main pass → HDR texture + depth buffer
|
||||
↓
|
||||
Bloom (si actif) → bloom_composite
|
||||
↓
|
||||
DoF (si actif) :
|
||||
CoC pass: depth → coc_texture (R16F, radius en px)
|
||||
Blur pass: color + coc → dof_output (Rgba16F)
|
||||
↓
|
||||
Tone Mapping → surface
|
||||
```
|
||||
|
||||
Quand DoF est désactivé : TM lit directement la texture HDR/bloom (zéro coût).
|
||||
|
||||
---
|
||||
Remplacer le modèle Lambert par un **PBR Metalness/Roughness** complet :
|
||||
- BRDF Cook-Torrance (GGX distribution + Smith visibility + Schlick Fresnel)
|
||||
- Workflow Metallic/Roughness (industriel : Unreal, Unity, Blender)
|
||||
- Normal mapping (tangent space, tangente dérivée — pas d'attribut tangent)
|
||||
- IBL analytique (hémisphère ciel/sol, pas de cubemap)
|
||||
- **Rétrocompatibilité** : les matériaux existants (metallic=0, roughness=0.5)
|
||||
rendent à peu près comme avant (diffuse + léger spéculaire)
|
||||
|
||||
## Décisions
|
||||
|
||||
### D1 — 2 passes : CoC + Blur
|
||||
### D1 — Workflow Metallic/Roughness
|
||||
|
||||
| Pass | Entrées | Sortie | Format |
|
||||
|------|---------|--------|--------|
|
||||
| CoC | depth texture | coc_texture | `R16Float` (1 canal, radius en pixels) |
|
||||
| Blur | color + coc | dof_output | `Rgba16Float` (4 canaux, couleur floutée) |
|
||||
|
||||
Le CoC est calculé séparément pour éviter de recalculer la linearisation du
|
||||
depth dans chaque tap du blur.
|
||||
|
||||
### D2 — Formule du CoC
|
||||
|
||||
```wgsl
|
||||
// Linearize NDC depth [0,1] → world distance (perspective)
|
||||
fn linearize_depth(ndc_z: f32, near: f32, far: f32) -> f32 {
|
||||
return near * far / (far - ndc_z * (far - near));
|
||||
}
|
||||
|
||||
// CoC in pixels:
|
||||
let dist = linearize_depth(depth, near, far);
|
||||
let coc = max_blur * aperture * abs(dist - focus_distance) / max(focus_distance, 1e-4);
|
||||
coc = min(coc, max_blur);
|
||||
```
|
||||
F0 = mix(vec3(0.04), base_color, metallic) // diélectrique: 4% reflexion, métal: albedo
|
||||
R = roughness² (GGX alpha)
|
||||
```
|
||||
|
||||
- `focus_distance` : distance (unités monde) où l'image est parfaitement nette
|
||||
- `aperture` : 0.0–1.0, contrôle l'intensité du flou (0 = pas de flou)
|
||||
- `max_blur` : radius maximum en pixels (clamp, évite le flou excessif)
|
||||
- `metallic ∈ [0, 1]` : 0 = diélectrique (dielectric), 1 = métal pur
|
||||
- `roughness ∈ [0, 1]` : 0 = miroir, 1 = totalement rugueux
|
||||
- Le `base_color` existant sert d'albedo (déjà présent via texture + vertex color)
|
||||
- **Pas de Specular/Glossiness** (workflow obsolète)
|
||||
|
||||
### D3 — Uniform struct (32 bytes)
|
||||
### D2 — Où stocker metallic/roughness
|
||||
|
||||
```wgsl
|
||||
struct DoFUniform {
|
||||
focus_distance: f32, // world units
|
||||
aperture: f32, // 0.0-1.0
|
||||
max_blur: f32, // pixels
|
||||
near: f32, // camera near plane
|
||||
far: f32, // camera far plane
|
||||
inv_width: f32, // 1.0 / texture width
|
||||
inv_height: f32, // 1.0 / texture height
|
||||
_pad: f32,
|
||||
Dans le **padding de `ObjectUniform`** (offset 80-87, juste après `emissive` à 64-79) :
|
||||
|
||||
```rust
|
||||
// WGSL:
|
||||
struct ObjectUniform {
|
||||
model: mat4x4<f32>, // 64 bytes (offset 0)
|
||||
emissive: vec4<f32>, // 16 bytes (offset 64)
|
||||
pbr: vec4<f32>, // 16 bytes (offset 80): (metallic, roughness, 0, 0)
|
||||
// ... padding jusqu'à 256 bytes
|
||||
};
|
||||
```
|
||||
|
||||
Un seul uniform partagé entre les 2 passes (CoC et Blur) — les valeurs sont
|
||||
identiques. Pas de ping-pong de buffers.
|
||||
- **Aucune modification du compute shader** (il n'écrit que bytes 0-63)
|
||||
- Ecrit via `queue.write_buffer` au moment du frame update (comme l'emissive)
|
||||
- `Material` gagne 2 champs : `metallic: f32`, `roughness: f32`
|
||||
|
||||
### D4 — Blur : disc 12-tap
|
||||
### D3 — Nouveau point d'entrée shader `fs_pbr`
|
||||
|
||||
Le blur utilise un pattern de 12 échantillons en disque (poisson-like),
|
||||
scallé par le CoC local :
|
||||
Le PBR est **plus complexe** que le Lambert actuel. Plutôt que de modifier
|
||||
`fs_main` en place (risque de régression), on ajoute un **deuxième point
|
||||
d'entrée fragment** `fs_pbr` dans le même module WGSL :
|
||||
|
||||
```text
|
||||
· ·
|
||||
· ·
|
||||
· ·
|
||||
· · ·
|
||||
· ·
|
||||
· ·
|
||||
· ·
|
||||
```
|
||||
@fragment fn fs_main(...) → Lambert (existant, pour rétrocompatibilité)
|
||||
@fragment fn fs_pbr(...) → PBR Cook-Torrance (nouveau)
|
||||
```
|
||||
|
||||
Chaque tap : `offset * coc_radius * texel_size`, pondéré uniformément (1/12).
|
||||
Le radius variable (par pixel) donne un bokeh naturel.
|
||||
La sélection est **compile-time** via le `shader_id` :
|
||||
- `Material::new(format, "standard", cache)` → pipeline avec `fs_main` (Lambert)
|
||||
- `Material::pbr(format, cache)` → pipeline avec `fs_pbr` (PBR)
|
||||
|
||||
> Pourquoi pas separable H+V comme bloom ? Le DoF produit un flou **circulaire**
|
||||
> (bokeh), pas un flou directionnel. Un disc blur single-pass est plus fidèle.
|
||||
> 12 taps × 1 texture = trivial GPU cost.
|
||||
Le vertex shader est **partagé** entre les deux (même `vs_main`).
|
||||
|
||||
### D5 — Textures
|
||||
### D4 — Normal mapping par tangente dérivée
|
||||
|
||||
| Texture | Format | Taille | Quand allouée |
|
||||
|---------|--------|--------|---------------|
|
||||
| `coc_texture` | `R16Float` | full-res (w×h) | DoF actif |
|
||||
| `dof_output` | `Rgba16Float` | full-res (w×h) | DoF actif |
|
||||
|
||||
Quand DoF est désactivé : **aucune** texture DoF n'est allouée. Zéro coût.
|
||||
|
||||
### D6 — API publique
|
||||
|
||||
```rust
|
||||
/// Configuration du Depth of Field.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct DoFConfig {
|
||||
/// Distance de focus (unités monde). L'image est nette à cette distance.
|
||||
pub focus_distance: f32,
|
||||
/// Intensité du flou (0.0 = aucun, 1.0 = max).
|
||||
pub aperture: f32,
|
||||
/// Radius maximum du flou en pixels.
|
||||
pub max_blur: f32,
|
||||
}
|
||||
|
||||
impl DoFConfig {
|
||||
/// DoF standard : focus à `distance`, flou modéré.
|
||||
pub fn new(focus_distance: f32, aperture: f32, max_blur: f32) -> Self;
|
||||
|
||||
/// Preset cinématique : flou prononcé, max_blur=12px.
|
||||
pub fn cinematic(focus_distance: f32) -> Self;
|
||||
|
||||
/// Preset subtil : léger flou en arrière-plan, max_blur=6px.
|
||||
pub fn subtle(focus_distance: f32) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
**Builder** :
|
||||
```rust
|
||||
AppBuilder::with_dof(DoFConfig::cinematic(5.0))
|
||||
```
|
||||
|
||||
**Runtime** :
|
||||
```rust
|
||||
app.renderer_mut().set_dof(Some(DoFConfig::new(3.0, 0.5, 8.0)));
|
||||
app.renderer_mut().set_dof(None); // désactiver
|
||||
```
|
||||
|
||||
### D7 — Pipeline integration
|
||||
|
||||
Dans `Renderer::render_scene` :
|
||||
|
||||
```rust
|
||||
// Après bloom (ou après main pass si pas de bloom) :
|
||||
if let Some(dof) = &self.dof_pipeline {
|
||||
// 1. CoC pass
|
||||
let mut coc_pass = encoder.begin_render_pass(&RenderPassDescriptor {
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &dof.coc_view,
|
||||
resolve_target: None,
|
||||
ops: ColorOps::ALL,
|
||||
format: TextureFormat::R16Float,
|
||||
..
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..
|
||||
});
|
||||
coc_pass.set_pipeline(&dof.coc_pipeline);
|
||||
coc_pass.set_bind_group(0, &dof.coc_bind_group, &[]);
|
||||
coc_pass.draw(0, 3, 0, 1);
|
||||
drop(coc_pass);
|
||||
|
||||
// 2. Blur pass
|
||||
let mut blur_pass = encoder.begin_render_pass(&RenderPassDescriptor {
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: &dof.output_view,
|
||||
resolve_target: None,
|
||||
ops: ColorOps::ALL,
|
||||
format: TextureFormat::Rgba16Float,
|
||||
..
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..
|
||||
});
|
||||
blur_pass.set_pipeline(&dof.blur_pipeline);
|
||||
blur_pass.set_bind_group(0, &dof.blur_bind_group, &[]);
|
||||
blur_pass.draw(0, 3, 0, 1);
|
||||
drop(blur_pass);
|
||||
|
||||
// 3. TM lit dof_output au lieu de HDR
|
||||
// (re-pointer le bind group TM)
|
||||
}
|
||||
```
|
||||
|
||||
### D8 — Shaders
|
||||
|
||||
#### `dof_coc.wgsl`
|
||||
**Pas d'attribut tangent** dans le vertex buffer (casserait tous les meshes existants).
|
||||
On utilise la méthode des **dérivées ecran-space** (mipmapped derivative tangent) :
|
||||
|
||||
```wgsl
|
||||
// Vertex : fullscreen triangle (identique à TM/bloom)
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
|
||||
// même triangle que TM : (-1,-1), (3,-1), (-1,3)
|
||||
}
|
||||
// Dans le fragment shader :
|
||||
let dpdx = dFdx(world_pos);
|
||||
let dpdy = dFdy(world_pos);
|
||||
let dwdx = dFdx(uv);
|
||||
let dwdy = dFdy(uv);
|
||||
|
||||
struct DoFUniform {
|
||||
focus_distance: f32,
|
||||
aperture: f32,
|
||||
max_blur: f32,
|
||||
near: f32,
|
||||
far: f32,
|
||||
inv_width: f32,
|
||||
inv_height: f32,
|
||||
_pad: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||||
@group(0) @binding(1) var depth_tex: texture_depth_2d;
|
||||
@group(0) @binding(2) var sampler: sampler;
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {
|
||||
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
|
||||
let ndc_z = textureSample(depth_tex, sampler, uv);
|
||||
|
||||
// Linearize: NDC [0,1] → world distance
|
||||
let dist = u.near * u.far / (u.far - ndc_z * (u.far - u.near));
|
||||
|
||||
// CoC in pixels
|
||||
var coc = u.max_blur * u.aperture * abs(dist - u.focus_distance)
|
||||
/ max(u.focus_distance, 1e-4);
|
||||
coc = min(coc, u.max_blur);
|
||||
|
||||
// Edge case: depth = 1.0 (far plane) → no blur
|
||||
if (ndc_z >= 0.9999) { coc = 0.0; }
|
||||
|
||||
return coc;
|
||||
}
|
||||
let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y);
|
||||
let bitangent = normalize(cross(n, tangent));
|
||||
let tbn = mat3x3<f32>(tangent, bitangent, n);
|
||||
```
|
||||
|
||||
#### `dof_blur.wgsl`
|
||||
Avantages :
|
||||
- Zéro changement de format vertex
|
||||
- Fonctionne avec n'importe quel mesh existant
|
||||
- Moins précis qu'un tangent explicite (artefacts possibles sur UV dégénérés)
|
||||
- Suffisant pour un premier PBR
|
||||
|
||||
Le normal map est échantillonné dans `@group(2) @binding(2)` (nouveau binding) :
|
||||
```
|
||||
vec3 nmap = textureSample(normal_texture, normal_sampler, uv).rgb * 2.0 - 1.0;
|
||||
vec3 n_pbr = normalize(tbn * nmap);
|
||||
```
|
||||
|
||||
Sans normal map → placeholder blanc (128,128,255) → `nmap = (0,0,1)` → `n_pbr = n` (aucun changement).
|
||||
|
||||
### D5 — IBL analytique (hémisphère)
|
||||
|
||||
Pas de cubemap pour cette étape. L'IBL est approximé par un **hémisphère 2 couleurs** :
|
||||
|
||||
```wgsl
|
||||
// Vertex : fullscreen triangle (id)
|
||||
// Sky/ground colors from frame.ambient (déjà présent)
|
||||
let ibl_dir = n; // direction de la normale (view space ou world)
|
||||
let ibl_sky = frame.ambient.rgb; // couleur "ciel"
|
||||
let ibl_ground = frame.ambient.rgb * 0.3; // couleur "sol" (assombrie)
|
||||
let ibl_color = mix(ibl_ground, ibl_sky, ibl_dir.y * 0.5 + 0.5);
|
||||
|
||||
struct DoFUniform { /* idem */ };
|
||||
// Specular IBL : approximation pré-filtrée par roughness
|
||||
// (réalité : cubemap pré-filtrée par mip ; ici : simple interpolation)
|
||||
let spec_ibl = mix(ibl_color, vec3<f32>(1.0), 0.5 * (1.0 - roughness));
|
||||
```
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: DoFUniform;
|
||||
@group(0) @binding(1) var color_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var coc_tex: texture_2d<f32>;
|
||||
@group(0) @binding(3) var sampler: sampler;
|
||||
C'est une approximation grossière mais suffisante pour :
|
||||
- Donner du "remplissage" aux zones non éclairées par les lumières ponctuelles
|
||||
- Faire varier le spéculaire IBL selon la roughness (mirroir = brillant, rugueux = mat)
|
||||
|
||||
const TAPS: array<vec2<f32>, 12> = array<vec2<f32>, 12>(
|
||||
vec2(0.0, 0.0),
|
||||
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(-1.0, 0.0),
|
||||
vec2(0.707, 0.707), vec2(0.707, -0.707),
|
||||
vec2(-0.707, 0.707), vec2(-0.707, -0.707),
|
||||
vec2(0.383, 0.924), vec2(-0.383, 0.924), vec2(0.383, -0.924),
|
||||
);
|
||||
### D6 — BRDF Cook-Torrance (GGX)
|
||||
|
||||
```wgsl
|
||||
fn distribution_ggx(n: vec3<f32>, h: vec3<f32>, roughness: f32) -> f32 {
|
||||
let a = roughness * roughness;
|
||||
let a2 = a * a;
|
||||
let ndh = max(dot(n, h), 0.0);
|
||||
let d = ndh * ndh * (a2 - 1.0) + 1.0;
|
||||
return a2 / (3.14159 * d * d);
|
||||
}
|
||||
|
||||
fn geometry_smith(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>, roughness: f32) -> f32 {
|
||||
let a = roughness * roughness;
|
||||
let kv = vec2<f32>(0.5, 0.5);
|
||||
let gv = n.y / (n.y * (1.0 - kv.y) + kv.x); // note: n.y ≈ |N·V| pour hémisphère local
|
||||
let kv2 = vec2<f32>(0.5, 0.5);
|
||||
let gl = n.y / (n.y * (1.0 - kv2.y) + kv2.x);
|
||||
return gv * gl;
|
||||
}
|
||||
|
||||
fn fresnel_schlick(cos_theta: f32, f0: vec3<f32>) -> vec3<f32> {
|
||||
return f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - cos_theta, 5.0);
|
||||
}
|
||||
|
||||
fn brdf_pbr(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>,
|
||||
base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
|
||||
let h = normalize(v + l);
|
||||
let f0 = mix(vec3<f32>(0.04), base, metallic);
|
||||
|
||||
let d = distribution_ggx(n, h, roughness);
|
||||
let g = geometry_smith(n, v, l, roughness);
|
||||
let f = fresnel_schlick(max(dot(h, v), 0.0), f0);
|
||||
|
||||
let ndl = max(dot(n, l), 0.0);
|
||||
let ndv = max(dot(n, v), 0.0);
|
||||
let ndh = max(dot(n, h), 0.0);
|
||||
let hv = max(dot(h, v), 0.0);
|
||||
|
||||
// Diffuse : Lambert × (1 - metallic) × (1 - F_D90)
|
||||
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
|
||||
let diffuse = kd * base / 3.14159;
|
||||
|
||||
// Speculaire : D × G × F / (4 × N·V × N·L)
|
||||
let denom = 4.0 * ndv * ndl + 1e-4;
|
||||
let specular = d * g * f / denom;
|
||||
|
||||
let radiance = (diffuse + specular) * base * ndl; // base = light color × intensity
|
||||
return radiance;
|
||||
}
|
||||
```
|
||||
|
||||
### D7 — Structure du fragment PBR
|
||||
|
||||
```wgsl
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let uv = pos.xy * vec2(u.inv_width, u.inv_height);
|
||||
let coc = textureSample(coc_tex, sampler, uv).r;
|
||||
fn fs_pbr(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
|
||||
let base = texel.rgb * in.color.rgb;
|
||||
|
||||
if (coc < 0.5) {
|
||||
// Below 0.5px: no blur needed
|
||||
return textureSample(color_tex, sampler, uv);
|
||||
// Unlit mode (même que fs_main)
|
||||
if (frame.options.x != 0u) {
|
||||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||||
return vec4<f32>(apply_fog(base + emissive_contrib, in.world_pos), in.color.a);
|
||||
}
|
||||
|
||||
let radius = coc; // in pixels
|
||||
var sum = vec4<f32>(0.0);
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
let offset = TAPS[i] * radius * vec2(u.inv_width, u.inv_height);
|
||||
sum += textureSample(color_tex, sampler, uv + offset);
|
||||
let metallic = object.pbr.x;
|
||||
let roughness = clamp(object.pbr.y, 0.045, 1.0); // min 0.045 (évite division par 0)
|
||||
|
||||
// Normal mapping (derivative tangent)
|
||||
let n = compute_pbr_normal(in); // inclut le normal map si présent
|
||||
|
||||
let v = normalize(frame.cam_pos - in.world_pos);
|
||||
var color = vec3<f32>(0.0);
|
||||
|
||||
// IBL (hémisphère analytique)
|
||||
let ibl = compute_ibl(n, roughness, base, metallic);
|
||||
color += ibl;
|
||||
|
||||
// Lumières directionnelles
|
||||
for (var i = 0u; i < frame.num_directional; i++) {
|
||||
let l = normalize(frame.lights[i].position_dir.xyz);
|
||||
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a;
|
||||
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color
|
||||
* compute_shadow(in.world_pos, n);
|
||||
}
|
||||
return sum / 12.0;
|
||||
|
||||
// Lumières ponctuelles + spots (même pattern, avec falloff)
|
||||
// ...
|
||||
|
||||
// Emissive
|
||||
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
|
||||
let final_rgb = color + emissive_contrib;
|
||||
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
|
||||
}
|
||||
```
|
||||
|
||||
### D9 — Bind group layouts
|
||||
### D8 — Texture normal map : nouveau binding `@group(2) @binding(2)`
|
||||
|
||||
**CoC pipeline** (3 bindings) :
|
||||
| Binding | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| 0 | Uniform (32B) | DoF params |
|
||||
| 1 | Texture (depth) | Depth buffer de la scène |
|
||||
| 2 | Sampler | Linear, clamp |
|
||||
Le `@group(2)` actuel a 2 bindings (sampler + diffuse texture). On ajoute :
|
||||
```
|
||||
@group(2) @binding(2) var normal_texture: texture_2d<f32>;
|
||||
@group(2) @binding(3) var normal_sampler: sampler;
|
||||
```
|
||||
|
||||
**Blur pipeline** (4 bindings) :
|
||||
| Binding | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| 0 | Uniform (32B) | DoF params |
|
||||
| 1 | Texture (color) | HDR/bloom color |
|
||||
| 2 | Texture (color) | CoC texture |
|
||||
| 3 | Sampler | Linear, clamp |
|
||||
- Sans normal map → placeholder (128,128,255) = normale neutre → aucun effet
|
||||
- Le `Material` gagne un champ `normal_texture: Option<Arc<Texture>>`
|
||||
- Le bind group group-2 est reconstruit avec la normal map (ou le placeholder)
|
||||
- **Le pipeline layout est le même** pour `fs_main` et `fs_pbr` (mêmes bindings)
|
||||
→ la PipelineCache peut partager le layout
|
||||
|
||||
Chaque pipeline a **son propre** pipeline layout (règle wgpu 30).
|
||||
|
||||
### D10 — `DoFPipeline` struct
|
||||
### D9 — `Material::pbr()` constructor
|
||||
|
||||
```rust
|
||||
pub(crate) struct DoFPipeline {
|
||||
// Textures
|
||||
coc_texture: Texture,
|
||||
coc_view: TextureView,
|
||||
output_texture: Texture,
|
||||
output_view: TextureView,
|
||||
impl Material {
|
||||
/// Crée un matériau PBR avec metallic/roughness.
|
||||
pub fn pbr(
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str, // "pbr"
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self { ... }
|
||||
|
||||
// Sampler (shared between both passes)
|
||||
sampler: Sampler,
|
||||
|
||||
// Pipelines
|
||||
coc_pipeline: RenderPipeline,
|
||||
blur_pipeline: RenderPipeline,
|
||||
|
||||
// Uniform buffer (shared: same values for both passes)
|
||||
uniform_buffer: Buffer,
|
||||
|
||||
// Bind groups
|
||||
coc_bind_group: BindGroup,
|
||||
blur_bind_group: BindGroup,
|
||||
/// Avec texture albedo + normal map.
|
||||
pub fn pbr_textured(
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
albedo: Option<Arc<Texture>>,
|
||||
normal_map: Option<Arc<Texture>>,
|
||||
cache: &mut PipelineCache,
|
||||
) -> Self { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Méthodes :
|
||||
- `DoFPipeline::new(device, width, height, depth_view, color_view)` → alloue tout
|
||||
- `DoFPipeline::update_uniform(&mut self, queue, config, near, far)` → écrit le buffer
|
||||
- `DoFPipeline::output_view(&self) -> &TextureView` → pour re-pointer le TM
|
||||
- `DoFPipeline::output_texture(&self) -> &Texture` → pour le bind group TM
|
||||
- `DoFPipeline::resize(...)` → recrée textures + bind groups
|
||||
### D10 — Rétrocompatibilité
|
||||
|
||||
### D11 — Resize
|
||||
- `Material::new()` (existant) → pipeline `fs_main` (Lambert) → **inchangé**
|
||||
- `Material::pbr()` (nouveau) → pipeline `fs_pbr` (PBR) → nouveau
|
||||
- Les deux pipelines coexistent dans la PipelineCache
|
||||
- Les examples existants (demo, bloom, fog, dof, etc.) continuent à utiliser `Material::new()`
|
||||
- **Aucune régression** : le shader `fs_main` n'est pas modifié
|
||||
|
||||
### D11 — Pipeline layout : 1 seul layout pour les 2 entry points
|
||||
|
||||
`fs_main` et `fs_pbr` lisent les **mêmes bindings** :
|
||||
- `@group(0)`: FrameUniforms
|
||||
- `@group(1)`: ObjectUniform
|
||||
- `@group(2)`: sampler + diffuse + normal_sampler + normal_texture
|
||||
|
||||
Un seul `BindGroupLayout` couvre les deux. La PipelineCache crée 2 pipelines
|
||||
(même layout, entry points différents) → partage du layout = zéro overhead supplémentaire.
|
||||
|
||||
### D12 — ObjectUniform : écriture du PBR data
|
||||
|
||||
Dans `render_scene`, l'écriture de l'emissive est déjà faite par `queue.write_buffer`
|
||||
à l'offset 64. On ajoute l'écriture de `pbr` à l'offset 80 :
|
||||
|
||||
Dans `resize_depth` (ou équivalent) :
|
||||
```rust
|
||||
if let Some(dof) = &mut self.dof_pipeline {
|
||||
dof.resize(device, queue, new_w, new_h, &new_depth_view, &new_color_view);
|
||||
// Étape 27 : PBR params (metallic, roughness) dans le padding de ObjectUniform.
|
||||
if mat.metallic != 0.0 || mat.roughness != 0.5 {
|
||||
let pbr_data: [f32; 4] = [mat.metallic, mat.roughness, 0.0, 0.0];
|
||||
let offset = (slot.slot_index as u64 * MAT_SLOT_SIZE + 80) as u64;
|
||||
self.queue.write_buffer(&self.matrix_buffer, offset, bytemuck::cast_slice(&pbr_data));
|
||||
}
|
||||
```
|
||||
|
||||
### D12 — Ordre des post-process
|
||||
Par défaut (metallic=0, roughness=0.5) → pas d'écriture → le buffer contient 0.0
|
||||
(le buffer est alloué avec `COPY_DST` et initialisé à zéro) → **c'est correct** :
|
||||
metallic=0 (diélectrique) et roughness=0.0...
|
||||
|
||||
```text
|
||||
Main pass → HDR
|
||||
→ Bloom (si actif) → bloom_composite
|
||||
→ DoF (si actif) → dof_output
|
||||
→ TM → surface
|
||||
```
|
||||
Hmm, roughness=0.0 est un problème (GGX avec alpha=0 → division par zéro).
|
||||
**Solution** : clamer `roughness = max(roughness, 0.045)` dans le shader (déjà prévu en D7).
|
||||
Le buffer initialisé à 0 → roughness=0 → clampé à 0.045 dans le shader → OK.
|
||||
|
||||
DoF **après** bloom : le glow du bloom est aussi flouté par le DoF → plus naturel.
|
||||
### D13 — Example `pbr.rs`
|
||||
|
||||
### D13 — Compatibilité
|
||||
Scène de démonstration :
|
||||
- **Sol** : plan 20×20, PBR (metallic=0, roughness=0.8) — surface matte
|
||||
- **Cube métal** : metallic=1.0, roughness=0.1 — miroir chromé
|
||||
- **Cube plastique** : metallic=0.0, roughness=0.4 — plastique lisse
|
||||
- **Cube rouillé** : metallic=0.8, roughness=0.7 — métal rugueux
|
||||
- **Sphere** : metallic=0.3, roughness=0.3 — céramique
|
||||
- **Cube normal map** : avec une normal map procédurale (bump)
|
||||
- 1 lumière directionnelle + 1 spot
|
||||
- Clavier : `R` = reset, `1` = varier roughness, `2` = varier metallic
|
||||
|
||||
| Avec | OK ? | Note |
|
||||
|------|------|------|
|
||||
| HDR | ✅ **requis** | DoF opère sur la texture HDR |
|
||||
| Bloom | ✅ | DoF après bloom (D12) |
|
||||
| MSAA | ✅ | Après resolve, DoF voit la texture single-sample |
|
||||
| Fog | ✅ | Fog est dans le main pass, DoF floute le résultat |
|
||||
| Culling | ✅ | Indépendant |
|
||||
### D14 — Normal map procédurale pour l'exemple
|
||||
|
||||
### D14 — `with_dof` sans `with_hdr` = no-op
|
||||
Générer une texture normal map 256×256 en code (pas de fichier externe) :
|
||||
- Pattern "bump" : sin(x*freq) * sin(y*freq) → normale perturbée
|
||||
- Ou pattern "bricks" : normales plates avec arêtes
|
||||
- Stockée dans un `wgpu::Texture` via `queue.write_texture`
|
||||
|
||||
Comme bloom, DoF nécessite HDR. `with_dof()` sans `with_hdr()` → warning + no-op.
|
||||
## Étapes d'implémentation
|
||||
|
||||
---
|
||||
| # | Tâche | Fichiers |
|
||||
|---|-------|----------|
|
||||
| 1 | `Material` : ajouter `metallic`, `roughness`, `normal_texture` + constructors `pbr()`/`pbr_textured()` | `resources/material.rs` |
|
||||
| 2 | `ObjectUniform` WGSL : ajouter `pbr: vec4<f32>` (offset 80) | `shaders/standard_shader.wgsl` |
|
||||
| 3 | Écrire le BRDF Cook-Torrance (GGX + Smith + Schlick) en WGSL | `shaders/standard_shader.wgsl` |
|
||||
| 4 | Écrire `fs_pbr` (IBL + boucle lumières + normal map) | `shaders/standard_shader.wgsl` |
|
||||
| 5 | Normal map bindings `@group(2) @binding(2,3)` + placeholder | `shaders/standard_shader.wgsl` + `pipeline_cache.rs` |
|
||||
| 6 | PipelineCache : créer pipeline `fs_pbr` (même layout, entry point différent) | `pipeline/pipeline_cache.rs` |
|
||||
| 7 | Renderer : écrire `pbr` data dans ObjectUniform (offset 80) | `core/renderer.rs` |
|
||||
| 8 | Bind group group-2 : inclure normal map (ou placeholder) | `resources/material.rs` |
|
||||
| 9 | WGSL validation test : vérifier que `fs_pbr` parsse | `tests/wgsl_validate.rs` |
|
||||
| 10 | Example `pbr.rs` : scène de démo + normal map procédurale | `examples/pbr.rs` |
|
||||
| 11 | Docs : examples/README.md + docs/user/pbr.md + ROADMAP | divers |
|
||||
|
||||
## Fichiers modifiés / créés
|
||||
## Risques et mitigations
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `lib/src/core/dof.rs` | **NEW** — `DoFConfig` + `DoFPipeline` |
|
||||
| `lib/src/core/mod.rs` | + `pub mod dof;` + re-exports |
|
||||
| `lib/src/lib.rs` | + `pub use DoFConfig` |
|
||||
| `lib/src/prelude.rs` | + `DoFConfig` |
|
||||
| `lib/src/core/renderer.rs` | + `dof` field, `set_dof()`, render pass, resize |
|
||||
| `lib/src/app.rs` | + `with_dof()`, plumbage App/Builder/Runner |
|
||||
| `lib/src/shaders/dof_coc.wgsl` | **NEW** |
|
||||
| `lib/src/shaders/dof_blur.wgsl` | **NEW** |
|
||||
| `lib/tests/wgsl_validate.rs` | + 2 shaders DoF |
|
||||
| `lib/examples/dof.rs` | **NEW** |
|
||||
| `lib/examples/README.md` | + section DoF |
|
||||
| `docs/user/dof.md` | **NEW** |
|
||||
| `docs/user/README.md` | + ligne DoF |
|
||||
| `docs/ROADMAP.md` | 6.17 → ✅ |
|
||||
| Risque | Mitigation |
|
||||
|--------|-----------|
|
||||
| GGX avec roughness≈0 → NaN | Clamp `roughness ≥ 0.045` dans le shader |
|
||||
| Dérivées ecran-space instables sur UV dégénérés (poles, seams) | Acceptable pour v1 ; tangent explicite en v2 |
|
||||
| Le PBR est "trop sombre" vs Lambert | Le `base/π` dans le diffuse PBR assombrit ; compenser par lumière plus intense ou exposure |
|
||||
| Normal map placeholder (128,128,255) → artefacts sur certains angles | Le mat3 TBN est orthonormalisé par `normalize` ; acceptable |
|
||||
| 2 pipelines (fs_main + fs_pbr) → mémoire GPU | ~2 pipelines × ~50KB = négligeable |
|
||||
|
||||
---
|
||||
## Critères d'acceptation
|
||||
|
||||
## Plan d'implémentation
|
||||
|
||||
| # | Tâche | Dépend |
|
||||
|---|-------|--------|
|
||||
| 1 | `core/dof.rs` : `DoFConfig` + tests | — |
|
||||
| 2 | `core/mod.rs` + `lib.rs` + `prelude.rs` : exports | 1 |
|
||||
| 3 | `shaders/dof_coc.wgsl` + `shaders/dof_blur.wgsl` | — |
|
||||
| 4 | `tests/wgsl_validate.rs` : ajouter les 2 shaders | 3 |
|
||||
| 5 | `core/dof.rs` : `DoFPipeline` (textures, pipelines, BGL, bind groups) | 3 |
|
||||
| 6 | `core/renderer.rs` : fields + `new` + `set_dof` + `render_scene` + `resize` | 5 |
|
||||
| 7 | `app.rs` : `with_dof()` + plumbage | 6 |
|
||||
| 8 | `examples/dof.rs` | 6 |
|
||||
| 9 | Docs : examples README + user docs + ROADMAP | 8 |
|
||||
| 10 | Vérification : `cargo check` + tests + examples | all |
|
||||
|
||||
---
|
||||
|
||||
## Estimation
|
||||
|
||||
- **Effort** : Moyen (~200 lignes Rust + ~80 lignes WGSL)
|
||||
- **Risque** : Bas (pattern identique à bloom, 2 passes simples)
|
||||
- **Gain visuel** : ⭐⭐⭐ (effet cinématique immédiat)
|
||||
- [ ] `Material::pbr(format, "pbr", metallic, roughness, cache)` compile et rend
|
||||
- [ ] Un cube metallic=1, roughness=0.1 a un reflet spéculaire net (miroir)
|
||||
- [ ] Un cube metallic=0, roughness=0.9 a un spéculaire large et diffus (mat)
|
||||
- [ ] Un cube avec normal map montre des bumps visibles
|
||||
- [ ] Les examples existants (demo, bloom, fog, dof) sont **inchangés** (fs_main)
|
||||
- [ ] `cargo test --workspace` : 0 failures
|
||||
- [ ] `cargo check -p wsg-lib --all-targets` : 0 warnings
|
||||
|
||||
+1
-1
@@ -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 | ✅ |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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<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
|
||||
/// 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<wgpu::Device>, 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<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 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<wgpu::RenderPipeline> {
|
||||
// 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<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();
|
||||
}
|
||||
|
||||
// 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<ColorTargetState>] — each wrapped in Some.
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
|
||||
@@ -27,12 +27,18 @@ pub struct Material {
|
||||
pub pipeline: Arc<wgpu::RenderPipeline>,
|
||||
/// Diffuse texture sampled by this material. `None` → the white placeholder is bound (DRAFT D1/D2).
|
||||
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
|
||||
/// 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<Texture>,
|
||||
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<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
|
||||
@@ -64,16 +118,20 @@ impl Material {
|
||||
format: wgpu::TextureFormat,
|
||||
shader_id: &str,
|
||||
texture: Option<Arc<Texture>>,
|
||||
normal_texture: Option<Arc<Texture>>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Texture>` can be created ergonomically by callers.
|
||||
pub(crate) fn arc(self) -> Arc<Texture> {
|
||||
Arc::new(self)
|
||||
|
||||
@@ -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::<ObjectUniform>(), 80);
|
||||
// Étape 27: ObjectUniform is now 96 bytes (64 matrix + 16 emissive + 16 pbr).
|
||||
assert_eq!(size_of::<ObjectUniform>(), 96);
|
||||
assert_eq!(align_of::<ObjectUniform>(), 16);
|
||||
assert_eq!(offset_of!(ObjectUniform, model), 0);
|
||||
assert_eq!(offset_of!(ObjectUniform, emissive), 64);
|
||||
assert_eq!(offset_of!(ObjectUniform, pbr), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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<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
|
||||
/// 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.
|
||||
|
||||
@@ -100,6 +100,7 @@ struct FrameUniforms {
|
||||
struct ObjectUniform {
|
||||
model: mat4x4<f32>, // 64 bytes (offset 0)
|
||||
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;
|
||||
@@ -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<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).
|
||||
// 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<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>,
|
||||
@location(4) tangent: vec3<f32>, // É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<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;
|
||||
}
|
||||
|
||||
@@ -291,3 +305,155 @@ fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> 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<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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user