Files
wsg/docs/DRAFT.md
T

14 KiB
Raw Blame History

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
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<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