Files
wsg/docs/DRAFT.md
T

220 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DRAFT — Étape 14 : Shadows (Phase 4.2, optionnel)
> **Objectif** : ajouter les **ombres portées** (shadow mapping) à `wsg-lib`, sur une **lumière
> unique** (directionnelle ou spot) choisie par la scène. C'est le dernier item optionnel restant
> de la Phase 4.2 (hémisphérique ✅, multi-lumières ✅ Étape 12, spot ✅ Étape 13, **shadows →
> ici**).
>
> **État de départ** : l'éclairage est entièrement CPU-packé dans `FrameUniforms` (liste
> `lights[8]`, caméra `view/proj`, `options[0]` = unlit). Le `Renderer` possède déjà une depth
> texture de scène (`_depth_texture`/`depth_view`) et un cache d'object bind groups
> (`object_cache`, par entité, modèle réécrit chaque frame). Le shader `standard_shader.wgsl` a
> un layout unique de 3 bind groups partagé par **toutes** les pipelines (Étapes 3 + 10) : frame
> @0, object @1, texture diffuse @2.
>
> **Définition de done** : un objet éclairé par la lumière ombreuse projette une ombre visible
> sur une surface adjacente (un sol), et l'ombre bouge quand le cube tourne. Vérifié au runtime
> via un exemple dédié (`shadow_test`). Aucune régression sur les exemples existants (`simple`,
> `cube`, `manual`, `spot_test`) — les ombres sont **éteintes par défaut**.
---
## 1. Modèle retenu : shadow mapping mono-lumière
Le shadow mapping classique se fait en **deux passes** :
1. **Pass ombre (depth-only)** : on rend la scène depuis le point de vue de la lumière dans une
texture de profondeur dédiée (la **shadow map**). Seule la profondeur compte — pas de couleur,
pas d'éclairage.
2. **Pass principale** : dans le fragment shader, on reprojette chaque fragment dans l'espace
lumière (`light_view_proj`), on compare sa profondeur à la shadow map. Si le fragment est plus
loin que l'occulteur enregistré, il est **à l'ombre** ; sinon il multiplie l'éclairage par 1.
### Portée (D1)
Une **seule** lumière ombreuse par frame, désignée par **son index dans le tableau packé**
(directionnelles d'abord, puis points, puis spots — même convention que `Lights::into_frame_array`).
Un index sentinelle `MAX_LIGHTS` (= 8) signifie « aucune ombre » → **éteint par défaut**
(non-régression). Les ombres **point** (cube map, 6 faces) sont **hors périmètre** (D6) — on couvre
les deux projections utiles :
- **directionnelle** → projection **orthographique** (soleil lointain) ;
- **spot** → projection **perspective** depuis le sommet du cône.
### Pourquoi ce choix ?
- Coût, lisibilité et validation simples : une texture, une passe, un VP lumière, un échantillon PCF.
- S'aligne sur l'architecture existante (matrices par frame, object bind group réutilisé).
- Le multi-shadow et les ombres point seront des extensions incrémentales (voir §6 « Évolutions »),
pas une réécriture.
---
## 2. Décisions (D1..D8)
| # | Décision |
|---|----------|
| **D1** | **Une seule lumière ombreuse** par frame, sélectionnée par index (tuple `(enable, index)`). Desactivée par défaut (index = `MAX_LIGHTS`). API : `Scene::set_shadow_caster(index)`. |
| **D2** | **Shadow map** : `DEPTH_FORMAT` (Depth32Float), résolution **1024×1024** par défaut, `usage = RENDER_ATTACHMENT | TEXTURE_BINDING`, 1 mip. Propriété du `Renderer` (comme la depth texture de scène). |
| **D3** | **Matrice `light_view_proj` calculée sur CPU** chaque frame par le `Renderer` (depuis `scene.shadow_caster()` + la lumière + un centre/rayon de scène). Ortho pour directionnelle, perspective pour spot. Uploadée dans `FrameUniforms` (tampon frame existant, `min_binding_size: None` → extension transparente). |
| **D4** | **Pipeline ombre dédiée** (vertex-only, `fragment: None`) : layout = [group 0 : `light_view_proj`, group 1 : `object_layout` **réutilisé**]. Elle réutilise donc directement `object_cache` (bind groups par entité déjà réécrits chaque frame). |
| **D5** | **PCF (Percentage Closer Filtering)** : échantillon comparateur (`sampler_comparison` + `texture_depth_2d`) + petit kernel 3×3 dans le shader, **slope-scaled depth bias** sur la pipeline ombre (`DepthBiasState { constant, slope_scale, clamp }`) + un petit bias constant dans la comparaison pour supprimer l'acné. |
| **D6** | **Ombre ponctuelle (cube map) hors périmètre** : documentée en §6, PAS implémentée à l'Étape 14. |
| **D7** | **Non-régression** : ombres éteintes par défaut → exemples `simple`/`cube`/`manual`/`spot_test` inchangés (pas de changement de leur rendu). Le groupe 3 (shadow) reste lié sur toutes les pipelines mais l'échantillonnage est porté par `options[1]`/index. |
| **D8** | **Résolution shadow** configurable plus tard ; constante `SHADOW_MAP_SIZE = 1024` maintenant (setter + recréation de texture reportés — voir §6). |
---
## 3. Plan d'implémentation
### 3.1 Étendre `FrameUniforms` (+ contrat WGSL) — uniform.rs / standard_shader.wgsl
Ajouter, après les compteurs, **avant** `options` (alignement 16 respecté par `repr(C)` comme par
WGSL) :
| Offset | Champ | Type | Sens |
|--------|-------|------|------|
| 672 | num_directional | u32 | (existant) |
| 676 | num_point | u32 | (existant) |
| 680 | num_spot | u32 | (existant) |
| 684 | **shadow_light_index** | u32 | index de la lumière ombreuse, `MAX_LIGHTS` = aucune |
| 688 | **light_view_proj** | mat4x4<f32> | VP de la lumière ombreuse |
| 752 | **shadow_params** | vec4<f32> | x = résolution (taille shadow map en pixels), y = bias, z/w = libres |
| 768 | options | vec4<u32> | (existant, `options[1]` = « ombres actives » 0/1) |
- Total : **784 octets** (au lieu de 704). `FRAME_UNIFORMS_SIZE = size_of` suit automatiquement ;
le `min_binding_size: None` du layout frame rend l'agrandissement transparent.
- **WGSL** `struct FrameUniforms` mis à jour à l'identique (même ordre, mêmes types) ; la table
d'offset du header du fichier est réindexée.
- **Tests** `frame_uniforms_layout_matches_wgsl` (uniform.rs) mis à jour : nouvel `offset_of`
pour `shadow_light_index` (684), `light_view_proj` (688), `shadow_params` (752), `options`
(768) et nouvelle taille 784.
### 3.2 Ressources shadow dans le `Renderer` — core/renderer.rs
Dans `Renderer::new`, allouer :
- `_shadow_texture: wgpu::Texture` + `shadow_view: wgpu::TextureView` (1024², Depth32Float,
`RENDER_ATTACHMENT | TEXTURE_BINDING`) — même pattern que `create_depth_texture`, helper isolé
`create_shadow_map(device, size)` ;
- `shadow_sampler: wgpu::Sampler` : `compare: Some(CompareFunction::GreaterEqual)` (voir D5) ,
`mag/min_filter: Linear`, `address_mode: ClampToEdge` — comparateur requis pour
`textureSampleCompare` ;
- `shadow_bind_group` : group 3 = [`shadow_sampler` (0), `shadow_view` (1)] ;
- `shadow_uniform_buffer` : 64 o (une `Mat4`), + `shadow_uniform_bind_group` (group 0 de la
pipeline ombre) ;
- `shadow_pipeline` : vertex-only (voir 3.3).
Nouveaux champs privés + les exposer par getters (au minimum `shadow_texture`,
`shadow_bind_group`, `shadow_pipeline`, `shadow_uniform_bind_group`) pour `render_scene`.
### 3.3 Pipeline ombre dédiée — pipeline/pipeline_cache.rs (helper) + renderer.rs
Nouvelle **`create_shadow_pipeline_layout(device, object_layout)`** et helper `build_shadow_pipeline` :
- Layout : [`shadow_uniform_layout` (group 0, buffer uniform VERTEX), `object_layout` (group 1)].
`object_layout` = exactement celui existant (même bind group par entité réutilisé). ✔
- `vertex` : `vs_main` d'un **nouveau shader minimal `shadow_shader.wgsl`** qui ne fait que :
`clip = light_view_proj * object.model * vec4(position,1)`. Entrées : uniquement `position`
(location 0). Pas de fragment (`fragment: None`).
- `depth_stencil` : `DEPTH_FORMAT`, `depth_write = true`, `compare = Less`, **`bias` slope-scaled**
(`constant = 2` , `slope_scale = 2.0`, clamp ~0) — anti-acné (D5).
Le shader ombre est ajouté en constante embarquée (`shadow_shader.wgsl` sous
`lib/src/shaders/`) et enregistrable via `PipelineCache::register_shader` comme les autres.
### 3.4 Pass ombre : `render_shadow_map` — core/renderer.rs
Nouvelle méthode, appelée **en tête de `render_scene`** (même `CommandEncoder`) :
1. Si `scene.shadow_caster()` est `None` → ne rien faire (ombre éteinte, `options[1] = 0`).
2. Calculer `light_view_proj` (D3, helper `light_view_proj(camera_setup, scene)` — voir 3.6),
l'écrire dans `shadow_uniform_buffer`.
3. `encoder.begin_render_pass` sur `shadow_view` (depth clear 1.0, pas de color attachment),
`set_pipeline(shadow_pipeline)`, pour chaque entité : `set_bind_group(0, shadow_uniform)` +
`set_bind_group(1, object_bind_group_for(label, transform))` + draw (indexed ou non).
4. Mettre `options[1] = 1` dans les frame uniforms.
`render` (bas niveau, sans scène) ne fait **pas** d'ombre — documenté (D7).
### 3.5 Échantillonnage PCF dans `standard_shader.wgsl`
- Déclarer le groupe 3 (ajouté à **toutes** les pipelines via `create_*_bind_group_layouts`) :
```
@group(3) @binding(0) var shadow_sampler: sampler_comparison;
@group(3) @binding(1) var shadow_texture: texture_depth_2d;
```
- Dans `fs_main`, **après** le calcul de `diffuse` et **une seule fois** (hors boucles lumière) :
1. `let in_shadow = 1.0 - sample_shadow(in.world_pos, frame);` avec `sample_shadow()` calculant
`light_uv` = NDC de `frame.light_view_proj * world_pos`, mappé en `[0,1]` (xy et z), puis
un **PCF 3×3** : moyenne de 9 `textureSampleCompare(shadow_texture, shadow_sampler, uv+off,
z-bias)` (offsets en texels / `shadow_params.x`).
2. `let lit = base * (ambient + diffuse * shadow_factor);` — l'ombre **ne touche pas l'ambiant**
(les ombres dures du soleil conservent une composante ambiante, look réaliste et pas de noir
total).
- Garde : si `frame.shadow_light_index == MAX_LIGHTS` (ou `options[1] == 0`) → `shadow_factor = 1`
(aucune ombre). Comportement identique aux exemples existants (D7).
- Le `light_view_proj` est un attribut **global** de frame (une seule lumière ombreuse, d'où la
porte _mono-lumière_ D1).
### 3.6 Helper de calcul `light_view_proj` — core/renderer.rs (ou math/)
`fn light_view_proj(light_index, lights, scene_center, area_radius) -> Mat4` :
- lire la lumière par `lights.into_frame_array()` → type par **position** (même logique que le
shader) ;
- **directionnelle** : `dir = normalize(position_dir.xyz)` (déjà « vers la lumière ») ;
position légère = `scene_center + dir * D` avec D grand (ex. `area_radius * 2 + 10`) ;
view = `look_at(light_pos, scene_center, up)` (up = Y, ou X si parallèle à Y) ;
proj = **ortho** `(-ext, ext, -ext, ext, near, far)` centrée sur la scène ;
- **spot** : view = `look_at(position_dir.xyz, position_dir.xyz + dir_angle.xyz, up)` ;
proj = **perspective** fov = `2 * acos(dir_angle.w)` (demi-angle stocké en cos), near = 0.1,
far = radius ;
- `scene_center`/`area_radius` : par défaut origine / constante (ex. `SHADOW_AREA_RADIUS = 5.0`),
surchargeables via `Scene` (optionnel à l'Étape 14 — constante en dur OK pour l'exemple).
### 3.7 API `Scene` — scene/scene.rs
- Nouveau champ `shadow_caster: Option<usize>` (+ getter `set_shadow_caster(index)`, getter
`shadow_caster()`).
- `None` par défaut → ombres éteintes (D7).
- Optionnel : `set_shadow_area(center, radius)` si on veut un réglage fin (reporté si inutile
pour l'exemple).
### 3.8 Exemple `shadow_test` — lib/examples/shadow_test.rs + README
- **Sol** (plan +Z ou XZ, quad gris) + **cube** posé dessus, éclairé par **une directionnelle**
qui caste une ombre (`scene.set_shadow_caster(0)` puisque la directionnelle par défaut est
l'index 0 après clean/setup).
- Cube qui **tourne** (réutiliser `Quat` composé de l'`spot_test`) → l'ombre sur le sol change de
forme/position à l'écran : preuve visuelle.
- Caméra fixe oblique (ou orbitale simple) pour bien voir l'ombre sur le sol.
- Ligne café : `cargo run -p wsg-lib --example shadow_test`.
- Documenter dans `lib/examples/README.md` (tableau des exemples, convention anglophone ✔).
---
## 4. Non-régression (obligatoire)
- `cargo build --workspace`, `cargo check --workspace` : verts.
- `cargo test --workspace` : tests `uniform.rs` (nouveaux offsets) + `lights.rs` inchangés passent.
- Exemples existants (`simple`, `cube`, `manual`, `spot_test`) : rendu **identique** (ombres
éteintes par défaut, groupe 3 lié mais non échantillonné quand `shadow_light_index == 8`).
- `cargo fmt --all` une fois le code formaté.
## 5. Vérification au runtime
1. `cargo run -p wsg-lib --example shadow_test` : un cube éclairé projette une ombre nette sur le
sol ; l'ombre **bouge** quand le cube tourne.
2. Toggle à la main (commenter `set_shadow_caster`) : ombre disparaît → le flag porte bien
l'activation.
3. Quelques frames : pas d'artefact type acné (PCF + bias D5). Zoom sur la jonction cube/sol.
4. (Optionnel) tenter une **spot** comme castor (`set_shadow_caster(..)` sur une spot) : ombre
perspective visible.
## 6. Évolutions (hors périmètre Étape 14)
- **Multi-ombres** : tableau de shadow maps + dépliage de `light_view_proj` en `[Mat4; N]` /
array de textures (array layer), PCF indexé par lumière. Change le contrat uniform → nouvelle
étape.
- **Ombre ponctuelle** : cube map 6 faces + `textureSampleCompare` sur `texture_depth_cube_array`.
- **Résolution / soft shadow dynamique** : `set_shadow_map_size` avec recréation de texture,
kernel PCF variable, blister/VSM.
- **Optimisation** : limiter la pass ombre aux seules entités dans le cône/frustum de la lumière.
---
## 7. Suivi (à compléter après implémentation)
> Remplir le bilan ici une fois l'étape livrée (comme pour les Étapes 12–13) : ce qui marche, ce
> qui a été vérifié, git-log du commit, et **vider** ce DRAFT pour l'étape suivante.
## 8. Ordre des commits (plan)
1. `feat(uniform): FrameUniforms étendu (shadow_light_index, light_view_proj, shadow_params)`
2. `feat(shadow): shader ombre + pipeline depth-only + ressources Renderer`
3. `feat(shadow): pass render_shadow_map + PCF dans standard_shader.wgsl`
4. `feat(scene): Scene::set_shadow_caster`
5. `test: exemple shadow_test + README des exemples`
6. (si besoin) `fix: bias/PCF — artefacts`