archi particules
This commit is contained in:
+378
-296
@@ -1,354 +1,436 @@
|
||||
# DRAFT — Étape 27 : PBR Metallic/Roughness + Normal Mapping (Phase 6.5)
|
||||
# Étape 28 — Système de Particules : Étape A (Pool)
|
||||
|
||||
> **Objectif** : Créer l'infrastructure GPU du pool de particules (buffer + pipeline render + draw).
|
||||
> C'est la brique de base sur laquelle les drivers (GPU/CPU/Manual) seront construits.
|
||||
> **Référence** : `docs/tech/ARCHI_PARTICULES.md` (§2, §3, §6, §7, §8.2, §8.3, §12, §13)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
La phase 6 "Post-MVP" a couvert les effets post-process (bloom, DoF, fog, MSAA) et le PBR.
|
||||
On passe maintenant au **système de particules** — une nouvelle catégorie de fonctionnalité
|
||||
(simulation + rendu) qui suit l'architecture Pool ≠ Driver décrite dans `ARCHI_PARTICULES.md`.
|
||||
|
||||
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.
|
||||
Les effets restants de la phase 6 (6.6 CSM, 6.7 SSAO, 6.14-6.16 Area lights / Volumetric)
|
||||
seront repris **après** le système de particules (phase 7).
|
||||
|
||||
## Objectif
|
||||
---
|
||||
|
||||
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)
|
||||
## Scope de cette étape (A)
|
||||
|
||||
## Décisions
|
||||
| Fait | Non fait (étapes suivantes) |
|
||||
|------|---------------------------|
|
||||
| Struct `Particle` (64 bytes, Pod) | Driver GPU (compute + spawn) — Étape B |
|
||||
| `ParticlePoolConfig` + `BlendingMode` | Driver CPU (simulation Rust) — Étape C |
|
||||
| `ParticlePool` (buffer + pipeline + bind group) | Driver Manual + handle — Étape D |
|
||||
| Pipeline render (billboard instancé) | Intégration Renderer (frame loop) — Étape E |
|
||||
| Vertex shader (quad via vertex_index + billboard) | Presets + Example — Étape F |
|
||||
| Fragment shader (texture × color) | Tests WGSL + layout — Étape G |
|
||||
| Texture par défaut (disque 16×16) | |
|
||||
| Méthodes `Scene::create_particle_pool` | |
|
||||
| Pool inactif par défaut (zéro draw sans driver) | |
|
||||
|
||||
### D1 — Workflow Metallic/Roughness
|
||||
> **Cette étape produit un pool qui EXISTE mais ne draw rien** (pas de driver = pas de count > 0).
|
||||
> Le draw sera activé à l'étape E (intégration Renderer). On peut néanmoins tester le pipeline
|
||||
> en forçant un count artificiel dans un test.
|
||||
|
||||
---
|
||||
|
||||
## Décisions (rappel de ARCHI_PARTICULES.md)
|
||||
|
||||
| # | Décision | Détail |
|
||||
|---|----------|--------|
|
||||
| D1 | Pool ≠ Driver | Le pool est la ressource GPU. Le driver est swappable. |
|
||||
| D2 | 64 bytes/particule | pos(12)+pad+vel(12)+pad+life+max_life+size+size_growth+angle+angular_vel+color(16) |
|
||||
| D3 | Billboard camera-facing | Quad orienté vers la caméra (axes right/up de la view matrix) |
|
||||
| D4 | Quad via `@builtin(vertex_index)` | Pas de vertex buffer. 4 sommets générés en shader. |
|
||||
| D5 | `draw(4, max_count)` + early-out | Le vertex shader skip les instances au-delà de `count_buffer` |
|
||||
| D6 | Blend figé au pipeline | 1 mode par pool (Additive ou Alpha) |
|
||||
| D7 | Depth test oui, depth write non | Transparence correcte |
|
||||
| D8 | Texture par défaut : disque 16×16 | Si `texture: None` |
|
||||
| D9 | Pool inactif si pas de driver | Zéro compute, zéro draw |
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
```
|
||||
F0 = mix(vec3(0.04), base_color, metallic) // diélectrique: 4% reflexion, métal: albedo
|
||||
R = roughness² (GGX alpha)
|
||||
lib/src/
|
||||
├── core/
|
||||
│ ├── mod.rs # + pub mod particles
|
||||
│ └── particles.rs # NOUVEAU : ParticlePool + ParticlePoolConfig + BlendingMode
|
||||
├── resources/
|
||||
│ ├── mod.rs # + re-export Particle
|
||||
│ └── particle.rs # NOUVEAU : struct Particle (64 bytes, Pod)
|
||||
├── shaders/
|
||||
│ ├── mod.rs # + PARTICLE_BILLBOARD_SHADER
|
||||
│ └── particle_billboard.wgsl # NOUVEAU : vs_main + fs_main
|
||||
├── scene/
|
||||
│ └── scene.rs # + particle_pools: HashMap<String, Arc<ParticlePool>>
|
||||
│ # + create_particle_pool()
|
||||
└── prelude.rs # + re-exports
|
||||
|
||||
lib/tests/
|
||||
└── wgsl_validate.rs # + test particle_billboard
|
||||
|
||||
lib/examples/
|
||||
└── particles.rs # (Étape F, pas cette étape)
|
||||
```
|
||||
|
||||
- `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)
|
||||
---
|
||||
|
||||
### D2 — Où stocker metallic/roughness
|
||||
## Détail des implémentations
|
||||
|
||||
Dans le **padding de `ObjectUniform`** (offset 80-87, juste après `emissive` à 64-79) :
|
||||
### 1. `resources/particle.rs`
|
||||
|
||||
```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
|
||||
};
|
||||
```
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
|
||||
- **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`
|
||||
|
||||
### D3 — Nouveau point d'entrée shader `fs_pbr`
|
||||
|
||||
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 :
|
||||
|
||||
```
|
||||
@fragment fn fs_main(...) → Lambert (existant, pour rétrocompatibilité)
|
||||
@fragment fn fs_pbr(...) → PBR Cook-Torrance (nouveau)
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
Le vertex shader est **partagé** entre les deux (même `vs_main`).
|
||||
|
||||
### D4 — Normal mapping par tangente dérivé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) :
|
||||
|
||||
```wgsl
|
||||
// Dans le fragment shader :
|
||||
let dpdx = dFdx(world_pos);
|
||||
let dpdy = dFdy(world_pos);
|
||||
let dwdx = dFdx(uv);
|
||||
let dwdy = dFdy(uv);
|
||||
|
||||
let tangent = normalize(dpdx * dwdy.y - dpdy * dwdx.y);
|
||||
let bitangent = normalize(cross(n, tangent));
|
||||
let tbn = mat3x3<f32>(tangent, bitangent, n);
|
||||
```
|
||||
|
||||
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
|
||||
// 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);
|
||||
|
||||
// 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));
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
### 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);
|
||||
/// 64 bytes per particle. Mirror of the WGSL `Particle` struct.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Pod, Zeroable, Default)]
|
||||
pub struct Particle {
|
||||
pub pos: [f32; 3], // offset 0
|
||||
pub _pad0: f32, // offset 12
|
||||
pub vel: [f32; 3], // offset 16
|
||||
pub _pad1: f32, // offset 28
|
||||
pub life: f32, // offset 32
|
||||
pub max_life: f32, // offset 36
|
||||
pub size: f32, // offset 40
|
||||
pub size_growth: f32, // offset 44
|
||||
pub angle: f32, // offset 48
|
||||
pub angular_vel: f32, // offset 52
|
||||
pub color: [f32; 4], // offset 56
|
||||
}
|
||||
|
||||
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;
|
||||
impl Particle {
|
||||
pub const SIZE: u64 = std::mem::size_of::<Self>() as u64; // must be 64
|
||||
}
|
||||
```
|
||||
|
||||
### D7 — Structure du fragment PBR
|
||||
**Test** : `assert_eq!(size_of::<Particle>(), 64)`, `assert_eq!(align_of::<Particle>(), 16)`.
|
||||
|
||||
### 2. `core/particles.rs`
|
||||
|
||||
```rust
|
||||
pub enum BlendingMode {
|
||||
Additive,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
pub struct ParticlePoolConfig {
|
||||
pub max_count: u32,
|
||||
pub texture: Option<String>, // ID dans scene.textures
|
||||
pub blending: BlendingMode,
|
||||
}
|
||||
|
||||
pub struct ParticlePool {
|
||||
pub(crate) buffer: wgpu::Buffer,
|
||||
pub(crate) pipeline: wgpu::RenderPipeline,
|
||||
pub(crate) bind_group: wgpu::BindGroup,
|
||||
pub(crate) sampler: wgpu::Sampler,
|
||||
pub(crate) count_buffer: wgpu::Buffer,
|
||||
pub max_count: u32,
|
||||
pub blending: BlendingMode,
|
||||
// Driver (Étape B/C/D) :
|
||||
pub(crate) driver: Option<Box<dyn ParticleDriver>>,
|
||||
pub(crate) active: bool,
|
||||
}
|
||||
```
|
||||
|
||||
**Construit** par `Scene::create_particle_pool` qui a accès au `device`, `queue`,
|
||||
`format`, et aux textures. Le pipeline est compilé immédiatement.
|
||||
|
||||
### 3. `shaders/particle_billboard.wgsl`
|
||||
|
||||
```wgsl
|
||||
// Particle billboard shader (vertex + fragment).
|
||||
// Quad generated via @builtin(vertex_index) — no vertex buffer.
|
||||
// Instance data read from storage buffer.
|
||||
|
||||
struct Particle {
|
||||
pos: vec3<f32>, pad0: f32,
|
||||
vel: vec3<f32>, pad1: f32,
|
||||
life: f32, max_life: f32,
|
||||
size: f32, size_growth: f32,
|
||||
angle: f32, angular_vel: f32,
|
||||
color: vec4<f32>,
|
||||
}
|
||||
|
||||
struct CameraParams {
|
||||
view: mat4x4<f32>,
|
||||
proj: mat4x4<f32>,
|
||||
}
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) frag_color: vec4<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: CameraParams;
|
||||
@group(0) @binding(1) var<storage, read> particles: array<Particle>;
|
||||
@group(0) @binding(2) var<uniform> count_buf: f32;
|
||||
|
||||
const QUAD: array<vec2<f32>, 4> = array<vec2<f32>, 4>(
|
||||
vec2(-0.5, -0.5),
|
||||
vec2( 0.5, -0.5),
|
||||
vec2( 0.5, 0.5),
|
||||
vec2(-0.5, 0.5),
|
||||
);
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vi: u32,
|
||||
@builtin(instance_index) ii: u32,
|
||||
) -> VsOut {
|
||||
var out: VsOut;
|
||||
|
||||
if f32(ii) >= count_buf {
|
||||
out.clip = vec4(0.0, 0.0, -2.0, 1.0);
|
||||
out.frag_color = vec4(0.0);
|
||||
out.uv = vec2(0.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
let p = particles[ii];
|
||||
let q = QUAD[vi];
|
||||
|
||||
let c = cos(p.angle);
|
||||
let s = sin(p.angle);
|
||||
let rot = vec2(q.x * c - q.y * s, q.x * s + q.y * c) * p.size;
|
||||
|
||||
let right = vec3(camera.view[0][0], camera.view[1][0], camera.view[2][0]);
|
||||
let up = vec3(camera.view[0][1], camera.view[1][1], camera.view[2][1]);
|
||||
|
||||
let world = p.pos + right * rot.x + up * rot.y;
|
||||
out.clip = camera.proj * camera.view * vec4(world, 1.0);
|
||||
out.frag_color = p.color;
|
||||
out.uv = q + vec2(0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
@group(0) @binding(3) var samp: sampler;
|
||||
@group(0) @binding(4) var tex: texture_2d<f32>;
|
||||
|
||||
@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 (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 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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let t = textureSample(tex, samp, in.uv);
|
||||
return in.frag_color * t;
|
||||
}
|
||||
```
|
||||
|
||||
### D8 — Texture normal map : nouveau binding `@group(2) @binding(2)`
|
||||
### 4. Bind group layout (render)
|
||||
|
||||
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;
|
||||
```
|
||||
| Binding | Type | Contenu | Visibility |
|
||||
|---------|------|---------|------------|
|
||||
| 0 | Uniform (min 112 B) | CameraParams (view + proj) | VERTEX |
|
||||
| 1 | Storage (RO) | particle_data | VERTEX |
|
||||
| 2 | Uniform (min 4 B) | count_buffer | VERTEX |
|
||||
| 3 | Sampler | Sampler | FRAGMENT |
|
||||
| 4 | Texture (2D) | Texture particule | FRAGMENT |
|
||||
|
||||
- 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
|
||||
|
||||
### D9 — `Material::pbr()` constructor
|
||||
### 5. Pipeline descriptor
|
||||
|
||||
```rust
|
||||
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 { ... }
|
||||
|
||||
/// 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 { ... }
|
||||
wgpu::RenderPipelineDescriptor {
|
||||
vertex: wgpu::VertexStage {
|
||||
module: shader,
|
||||
entry_point: "vs_main",
|
||||
buffers: &[], // PAS de vertex buffer
|
||||
},
|
||||
fragment: Some(wgpu::FragmentStage {
|
||||
module: shader,
|
||||
entry_point: "fs_main",
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
// Indices : pas de index buffer → on utilise draw(4, N)
|
||||
// MAIS : 4 sommets sans indices = 2 triangles ? NON.
|
||||
// draw(4, N) drawe 4 triangles (4 indices implicites 0,1,2,3) = 1 triangle + 1 degénéré.
|
||||
// IL FAUT un index buffer ! Ou utiliser draw_indexed.
|
||||
// → Voir GOTCHA ci-dessous.
|
||||
..Default::default()
|
||||
},
|
||||
color_states: [wgpu::ColorState {
|
||||
format,
|
||||
alpha_blend: blend_alpha,
|
||||
color_blend: blend_color,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}],
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: depth_format,
|
||||
depth_write_enabled: false,
|
||||
depth_compare: wgpu::CompareFunction::LessEqual,
|
||||
..Default::default()
|
||||
}),
|
||||
multisample,
|
||||
..
|
||||
}
|
||||
```
|
||||
|
||||
### D10 — Rétrocompatibilité
|
||||
### ⚠️ GOTCHA : Topologie du quad billboard
|
||||
|
||||
- `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é
|
||||
**Problème** : `draw(4, N)` sans index buffer drawe 4 **vertices** en `TriangleList`,
|
||||
ce qui fait 4/3 = 1 triangle + 1 vertex orphelin. Ce n'est PAS un quad.
|
||||
|
||||
### D11 — Pipeline layout : 1 seul layout pour les 2 entry points
|
||||
**Solutions** :
|
||||
|
||||
`fs_main` et `fs_pbr` lisent les **mêmes bindings** :
|
||||
- `@group(0)`: FrameUniforms
|
||||
- `@group(1)`: ObjectUniform
|
||||
- `@group(2)`: sampler + diffuse + normal_sampler + normal_texture
|
||||
| Option | Pro | Contre |
|
||||
|--------|-----|--------|
|
||||
| A : `draw(6, N)` + 6 sommets (quad = 2 tris, 6 verts) | Pas d'index buffer | 6 vertices au lieu de 4 (2 dupliqués) |
|
||||
| B : Index buffer (6 indices) + `draw_indexed(6, N, 0, 0)` | 4 vertices seulement | 1 petit buffer index (24 bytes) partagé |
|
||||
| C : `@builtin(vertex_index)` avec 6 values dans le const | Pas d'index buffer, pas de vertex buffer | Le const a 6 entries au lieu de 4 |
|
||||
|
||||
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.
|
||||
**Décision : Option C** — 6 entries dans le const QUAD, `draw(6, max_count)`.
|
||||
|
||||
### D12 — ObjectUniform : écriture du PBR data
|
||||
```wgsl
|
||||
// 6 entries = 2 triangles (0-1-2, 3-4-5) formant un quad
|
||||
const QUAD: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
|
||||
vec2(-0.5, -0.5), // 0
|
||||
vec2( 0.5, -0.5), // 1
|
||||
vec2( 0.5, 0.5), // 2
|
||||
vec2(-0.5, -0.5), // 3
|
||||
vec2( 0.5, 0.5), // 4
|
||||
vec2(-0.5, 0.5), // 5
|
||||
);
|
||||
```
|
||||
|
||||
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 :
|
||||
→ `draw(6, max_count)`. Pas de vertex buffer, pas d'index buffer. Cohérent avec
|
||||
le pattern fullscreen triangle du TM/bloom (qui utilise `draw(3, 1)`).
|
||||
|
||||
### 6. Texture par défaut (disque 16×16)
|
||||
|
||||
Générée en Rust au build du pool (si `config.texture == None`) :
|
||||
|
||||
```rust
|
||||
// É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));
|
||||
fn default_disc_texture() -> Vec<u8> {
|
||||
let size = 16;
|
||||
let mut data = vec![0u8; size * size * 4];
|
||||
let center = (size as f32 - 1.0) / 2.0;
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let dx = (x as f32 - center) / center;
|
||||
let dy = (y as f32 - center) / center;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
let alpha = (1.0 - dist).clamp(0.0, 1.0) as u8 * 255;
|
||||
let i = (y * size + x) * 4;
|
||||
data[i] = 255; // R
|
||||
data[i+1] = 255; // G
|
||||
data[i+2] = 255; // B
|
||||
data[i+3] = alpha; // A
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
```
|
||||
|
||||
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...
|
||||
### 7. `Scene::create_particle_pool`
|
||||
|
||||
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.
|
||||
```rust
|
||||
impl Scene {
|
||||
pub fn create_particle_pool(&mut self, id: &str, config: ParticlePoolConfig) -> Result<(), String> {
|
||||
if self.particle_pools.contains_key(id) {
|
||||
return Err(format!("particle pool '{}' already exists", id));
|
||||
}
|
||||
// Résoudre la texture
|
||||
let (texture_view, sampler, is_owned) = match &config.texture {
|
||||
Some(tex_id) => {
|
||||
let tex = self.textures.get(tex_id)
|
||||
.ok_or_else(|| format!("texture '{}' not found", tex_id))?;
|
||||
(tex.view.clone(), tex.sampler.clone(), false)
|
||||
}
|
||||
None => {
|
||||
// Créer la texture disque 16×16
|
||||
let (view, sampler) = self.gpu.create_default_disc_texture();
|
||||
(view, sampler, true)
|
||||
}
|
||||
};
|
||||
// Construire le pool (buffer + pipeline + bind group)
|
||||
let pool = ParticlePool::new(
|
||||
&self.gpu.device,
|
||||
&self.gpu.queue,
|
||||
self.gpu.format,
|
||||
self.gpu.depth_format,
|
||||
self.gpu.msaa,
|
||||
&config,
|
||||
texture_view,
|
||||
sampler,
|
||||
);
|
||||
self.particle_pools.insert(id.to_string(), Arc::new(pool));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### D13 — Example `pbr.rs`
|
||||
### 8. Prelude
|
||||
|
||||
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
|
||||
```rust
|
||||
// Dans prelude.rs :
|
||||
pub use crate::core::particles::{ParticlePoolConfig, BlendingMode};
|
||||
pub use crate::resources::particle::Particle;
|
||||
```
|
||||
|
||||
### D14 — Normal map procédurale pour l'exemple
|
||||
---
|
||||
|
||||
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`
|
||||
## Blend states
|
||||
|
||||
## Étapes d'implémentation
|
||||
| Mode | color_ops.src | color_ops.dst | alpha_ops.src | alpha_ops.dst |
|
||||
|------|--------------|--------------|---------------|---------------|
|
||||
| **Additive** | One | One | One | One |
|
||||
| **Alpha** | SrcAlpha | OneMinusSrcAlpha | One | OneMinusSrcAlpha |
|
||||
|
||||
| # | 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 |
|
||||
---
|
||||
|
||||
## Risques et mitigations
|
||||
## Tests
|
||||
|
||||
| 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 |
|
||||
### Unit tests (`particles.rs`)
|
||||
|
||||
| Test | Vérifie |
|
||||
|------|---------|
|
||||
| `particle_size_is_64` | `size_of::<Particle>() == 64` |
|
||||
| `particle_align_is_16` | `align_of::<Particle>() == 16` |
|
||||
| `particle_offsets` | Offsets de chaque champ |
|
||||
| `pool_config_default_max_count` | Valeur raisonnable |
|
||||
| `default_disc_texture_size` | 16×16×4 bytes |
|
||||
| `default_disc_center_is_opaque` | Center pixel alpha = 255 |
|
||||
| `default_disc_corner_is_transparent` | Corner pixel alpha = 0 |
|
||||
|
||||
### WGSL validation (`wgsl_validate.rs`)
|
||||
|
||||
| Test | Vérifie |
|
||||
|------|---------|
|
||||
| `particle_billboard_compiles` | Naga compile le shader |
|
||||
| `particle_billboard_entry_points` | Contient `vs_main` + `fs_main` |
|
||||
| `particle_billboard_no_compute` | Pas d'entry point compute (cette étape) |
|
||||
|
||||
---
|
||||
|
||||
## Vérification de non-régression
|
||||
|
||||
- [ ] `cargo check -p wsg-lib --all-targets` → 0 errors, 0 warnings
|
||||
- [ ] `cargo test -p wsg-lib` → tous les tests existants passent (127+)
|
||||
- [ ] Les examples existants (demo, pbr, bloom, etc.) compilent et fonctionnent
|
||||
- [ ] Aucun changement dans `renderer.rs` (le pool n'est pas encore intégré au frame loop)
|
||||
- [ ] `Scene` a un nouveau champ `particle_pools` mais il est vide par défaut → zéro coût
|
||||
|
||||
---
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] `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. ✅ `Particle` compile, 64 bytes, Pod, offsets corrects
|
||||
2. ✅ `particle_billboard.wgsl` compile par Naga (test WGSL)
|
||||
3. ✅ `ParticlePool::new` crée buffer + pipeline + bind group sans erreur
|
||||
4. ✅ La texture disque 16×16 est générée correctement
|
||||
5. ✅ `Scene::create_particle_pool` fonctionne (test unitaire avec mock device)
|
||||
6. ✅ Le pool est inactif (pas de draw) tant qu'aucun driver n'est attaché
|
||||
7. ✅ Zéro warning, tous les tests verts
|
||||
8. ✅ Prelude expose les types
|
||||
|
||||
---
|
||||
|
||||
## Étape suivante (B)
|
||||
|
||||
Driver GPU : compute shader `particle_update.wgsl` + `GpuEmitterConfig` +
|
||||
spawn CPU + dispatch + `Scene::attach_gpu_emitter`.
|
||||
|
||||
Reference in New Issue
Block a user