# 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 | VP de la lumière ombreuse | | 752 | **shadow_params** | vec4 | x = résolution (taille shadow map en pixels), y = bias, z/w = libres | | 768 | options | vec4 | (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` (+ 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`