14 KiB
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(listelights[8], caméraview/proj,options[0]= unlit). LeRendererpossè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 shaderstandard_shader.wgsla 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 :
- 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.
- 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 |
| 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_ofsuit automatiquement ; lemin_binding_size: Nonedu layout frame rend l'agrandissement transparent. - WGSL
struct FrameUniformsmis à 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 : nouveloffset_ofpourshadow_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 quecreate_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 pourtextureSampleCompare;shadow_bind_group: group 3 = [shadow_sampler(0),shadow_view(1)] ;shadow_uniform_buffer: 64 o (uneMat4), +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_maind'un nouveau shader minimalshadow_shader.wgslqui ne fait que :clip = light_view_proj * object.model * vec4(position,1). Entrées : uniquementposition(location 0). Pas de fragment (fragment: None).depth_stencil:DEPTH_FORMAT,depth_write = true,compare = Less,biasslope-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) :
- Si
scene.shadow_caster()estNone→ ne rien faire (ombre éteinte,options[1] = 0). - Calculer
light_view_proj(D3, helperlight_view_proj(camera_setup, scene)— voir 3.6), l'écrire dansshadow_uniform_buffer. encoder.begin_render_passsurshadow_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).- Mettre
options[1] = 1dans 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 dediffuseet une seule fois (hors boucles lumière) :let in_shadow = 1.0 - sample_shadow(in.world_pos, frame);avecsample_shadow()calculantlight_uv= NDC deframe.light_view_proj * world_pos, mappé en[0,1](xy et z), puis un PCF 3×3 : moyenne de 9textureSampleCompare(shadow_texture, shadow_sampler, uv+off, z-bias)(offsets en texels /shadow_params.x).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(ouoptions[1] == 0) →shadow_factor = 1(aucune ombre). Comportement identique aux exemples existants (D7). - Le
light_view_projest 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 * Davec 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 viaScene(optionnel à l'Étape 14 — constante en dur OK pour l'exemple).
3.7 API Scene — scene/scene.rs
- Nouveau champ
shadow_caster: Option<usize>(+ getterset_shadow_caster(index), gettershadow_caster()). Nonepar 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
Quatcomposé 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: testsuniform.rs(nouveaux offsets) +lights.rsinchangés passent.- Exemples existants (
simple,cube,manual,spot_test) : rendu identique (ombres éteintes par défaut, groupe 3 lié mais non échantillonné quandshadow_light_index == 8). cargo fmt --allune fois le code formaté.
5. Vérification au runtime
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.- Toggle à la main (commenter
set_shadow_caster) : ombre disparaît → le flag porte bien l'activation. - Quelques frames : pas d'artefact type acné (PCF + bias D5). Zoom sur la jonction cube/sol.
- (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_projen[Mat4; N]/ array de textures (array layer), PCF indexé par lumière. Change le contrat uniform → nouvelle étape. - Ombre ponctuelle : cube map 6 faces +
textureSampleComparesurtexture_depth_cube_array. - Résolution / soft shadow dynamique :
set_shadow_map_sizeavec 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)
feat(uniform): FrameUniforms étendu (shadow_light_index, light_view_proj, shadow_params)feat(shadow): shader ombre + pipeline depth-only + ressources Rendererfeat(shadow): pass render_shadow_map + PCF dans standard_shader.wgslfeat(scene): Scene::set_shadow_castertest: exemple shadow_test + README des exemples- (si besoin)
fix: bias/PCF — artefacts