Files
wsg/docs/ROADMAP.md
T
Jérôme Bousquié 40fac63590 LOD: fix inverted normals on decimated meshes + rim protection
Root cause of the user-reported artifacts (stripes disappearing on the
far LOD): normals were RECOMPUTED from the surviving faces after the
collapse. uv_sphere is wound inward, so the recomputed normals pointed
inward — the far LOD was back-face-lit (measured deviation 2.0 vs 0.0
on L0).

Fix — normals are INHERITED, never recomputed:
- welded() now also welds normals (first-encountered per cluster)
- Collapse owns the normal table; collapse_edge λ-blends + renormalizes
  at the same λ as the position (no seam guard: the attribute-aware
  weld kept hard-edge vertices separate, so no edge crosses a shading
  break)
- compaction reads the post-collapse table instead of recomputing
- the outward reorientation added earlier is removed: the source
  winding + normals are preserved as-is, so every LOD level is
  shading-compatible with L0 whatever the source orientation

Rim protection (attribute-aware weld leaves UV-seam slits / pole fans
as boundary rims): a face touching such a rim is never removed while
interior collapses remain — strict PQ mode (edges whose incident faces
are fully interior, re-validated at pop) with a best-effort fallback
when the interior alone cannot reach the target. Seam-free meshes
(icosahedron) stay topologically closed; sewn meshes stay geometrically
complete (no hole at the slit) — tests now assert seam-column survival.

Docs: gpu-driven.md §LOD, ARCHI_CPU_GPU LOD note, ROADMAP 4.3 updated
with the attribute-aware weld + rim protection + inherited normals.

Gate: fmt ✓, check 0 warnings ✓, 104 tests ✓, demo runs ✓.
Measured (uv_sphere 32×20): normal max deviation 0.0000 on L0–L2
(was 2.0000), UV max error 0.0084 vs analytical (was 0.5000).
2026-09-23 15:01:56 +02:00

203 lines
18 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.
---
type: Roadmap
title: WSG Engine Development Roadmap
description: Development roadmap for the WSG engine from prototype to full-featured 3D rendering engine
tags: [roadmap, development, planning, wsg-lib, 3d-rendering]
status: stable
generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
---
# Roadmap WSG — Prototype → Moteur Complet
> Basé sur l'architecture existante (ARCHI_APP, ARCHI_ARENES, ARCHI_CPU_GPU, ARCHI_RENDU).
> Objectif : prototype fonctionnel d'abord, enrichissement progressif ensuite.
>
> **Point de départ (état réel au 2026-09-16 — la source de vérité est README.md).**
> Les fondations suivantes existent et fonctionnent déjà ; cette roadmap décrit la **trajectoire à
> venir** à partir de cet état (elle reprend les étapes 1-4 du README avant la montée GPU-driven) :
> - Workflow manuel (`Context` + `Renderer` + `PipelineCache`) : ✅ fonctionnel (exemple `manual`).
> - Façade `App` / `AppBuilder` / `AppHandler` : ✅ **Scene auto-render** (2026-09-16) — la vue de frame
> est exposée (`Frame::view()`), `render()` dessine la scène en une passe groupée
> (`App::render_scene`) et la présentation est automatique dans `App::run` (exemple `simple`).
> - `Scene` avec identifiants **String** (décision prise — voir tableau Notes de Décision) : 🚧 enregistrement seul.
> - `Camera` / `Transform` et `glam` : types et mathématiques présents (`math/`, `resources/camera.rs`),
> initialement non branchés au pipeline — **désormais branchés** (caméra active + matrices monde écrites
> chaque frame, Étape 4.3, 2026-09-16 ; voir §1.1/1.5 ci-dessous).
> **Étape suivante (résolue 2026-09-17).** « 3D + éclairage Phong » (ROADMAP 1.3 + 1.5) est **atteinte** :
> le rendu automatique n'est plus plat. L'infrastructure (Étapes 3+4, 2026-09-16) — `standard_shader.wgsl`
> Phong (matrice `projection * view * world` + lumière directionnelle), uniform buffers branchés
> (frame : view/proj/cam_pos + lumière ; par mesh : `world` dérivé du `Transform`), `Renderer` écrivant
> chaque frame la caméra active et la matrice monde de chaque entité — est **branchée** sur l'exemple
> `cube` (Étape 5, 2026-09-17) : un cube unitaire éclairé qui tourne à l'écran via `App::render_scene`.
> Le shader `basic` est supprimé : le 2D plat devient la variante **unlit** de `standard`
> (`Renderer::set_unlit(true)`). Objectif MVP **atteint**.
---
## Phase 1️⃣ — Prototype MVP : Un Mesh 3D éclairé à l'écran
**Objectif** : Afficher un cube (ou autre mesh) 3D avec un éclairage Phong basique.
### 1.1 Dépendances & Mathématiques
- [x] `glam = "0.33"` ajouté (`lib/Cargo.toml`) — déjà présent, utilisé par `math/transform.rs` et `resources/camera.rs`
- [x] `slotmap` **retiré** — décision prise : **String IDs pour le MVP** ; slotmap reporté à l'étape "handles typés" (voir Notes de Décision)
- [x] Module `math/` / `transform.rs`:
- [x] Struct `Transform { translation: Vec3, rotation: Quat, scale: Vec3 }`
- [x] Méthode `to_matrix() -> Mat4` pour calculer la matrice locale
- [x] Struct `Camera { position: Vec3, target: Vec3, up: Vec3 }` : resources/camera.rs — enrichi en Étape 4.3 (fov/near/far + `with_perspective`)
- [x] Fonctions `view_matrix()` et `projection_matrix(fov, aspect, near, far)` (Étape 4.3 : `projection_matrix(aspect)` utilise fov/near/far stockés)
### 1.2 Geometry & Mesh
- [x] Créer struct `Geometry` (math/geometry.rs) — **fait** :
- [x] `positions: Vec<[f32; 3]>` (obligatoire)
- [x] `indices: Option<Vec<u16>>` (optionnel)
- [x] `normals: Option<Vec<[f32; 3]>>` (pour Phong) — plus `uvs: Option<Vec<[f32; 2]>>`
- [x] `colors: Option<Vec<[f32; 4]>>` — **fait (Étape 8, 8.1, 2026-09-18)** : décidé en DRAFT Étape 8 (D1) ;
le shader lit la couleur unlit, elle est donc portée dans `Geometry`. Conversion
`Geometry -> Vec<Vertex>` via `Geometry::to_vertices()` (D6) pour l'upload.
- [x] Refactorer `Mesh` pour contenir — **fait (Étape 8, 8.3, 2026-09-18)** :
- [x] `geometry: Arc<Geometry>` (rétention CPU, D5) + accesseur `geometry()`
- [x] `vertex_buffer: wgpu::Buffer`
- [x] `index_buffer: Option<wgpu::Buffer>`
- Construction via `Mesh::from_geometry(device, Arc<Geometry>, material)` (D4) ; les anciennes
voies `Mesh::new`/`with_material` (`&[Vertex]`) sont supprimées.
- `Scene::create_mesh(id, Geometry, Option<&str>)` (8.4) ; exemples cube/simple/manual réécrits
sur `Geometry` (8.5).
- [x] Ajouter un mesh de test (cube unitaire) en exemple — **fait** (helper `cube_geometry` dans l'exemple `cube`, Étape 5, 2026-09-17)
> **État (2026-09-18)** : `Mesh` porte son matériau (`mesh.material: Option<Arc<Material>>`, Étape 7) et
> une source de vérité CPU partagée (`geometry: Arc<Geometry>`, Étape 8). `Mesh` ne porte **pas** de
> `transform` : un même mesh est partagé par plusieurs entités ; le `transform` vit sur `Entity`.
### 1.3 Shader Phong Minimal
- [x] Créer `standard_shader.wgsl` (Étape 2, 2026-09-16) :
- [x] Vertex shader : projection * view * world * position
- [x] Fragment shader : éclairage directionnel (+ hémisphérique)
- [x] Uniforms : `view`, `proj`, `cam_pos`, `light_dir`, `light_color`, `options`
- [x] Mettre à jour `Material` / pipeline pour supporter les uniforms du shader Phong (bind group layouts frame+object, Étape 3) — **désormais branché** sur l'exemple `cube` (Étape 5, 2026-09-17)
### 1.4 Scene avec identifiants (MVP : String IDs)
- [x] `Scene` implémentée avec **String IDs** (`HashMap<String, Arc<Mesh>>`, `...Material`, entités) — état actuel validé ; décision : rester en String IDs pour le MVP
- [x] Méthodes : `add_mesh()`, `get_mesh()`, `add_material()`, `add_entity()`, `iter_entities()`, `remove_entity()`
- [x] Caméra active dans la `Scene` : `set_camera()` / `camera()` (Étape 4.3)
- [ ] **Reporté (étape "Handles typés")** : migrer vers `slotmap` générationnel (`MeshId`/`MaterialId`) quand l'éviction/les performances le justifieront
### 1.5 Rendu du Prototype
- [x] Uniform buffer pour la frame : `view`, `proj`, `cam_pos`, `light_dir` (Étapes 3+4) — écrit chaque frame depuis la caméra active
- [x] Uniform buffer par mesh : `world` (calculée sur CPU depuis `transform.to_matrix()`, Étape 4.2)
- [x] `Renderer::render_scene()` itère sur les entités de la Scene et dessine chacune (liaison bind groups frame+object)
- [x] Exemple fonctionnel : un cube éclairé tourne à l'écran — **fait** (Étape 5, 2026-09-17 : brancher `standard` sur l'exemple `cube` + mesh cube + rotation via `App::render_scene`)
---
## Phase 2️⃣ — Scène enrichie
**Objectif** : Étoffer la `Scene` au-delà du MVP.
> Les ressources sont, pour le MVP, identifiées par **String IDs** (décision actée — voir Notes de
> Décision). Une migration vers des **handles typés** (slotmap générationnel) reste planifiée quand
> l'éviction/les performances le justifieront (voir 1.4, « reporté »).
### 2.1 Camera dans la Scene
- [x] Intégrer `Camera` comme ressource de la Scene (Étape 4.3 : `Scene::set_camera` / `camera()`, caméra active unique)
- [ ] Permettre plusieurs caméras (actuelle/inactive) et une sélection par identifiant (`scene.set_active_camera(camera_id)`)
- [x] Exposer une caméra orbitale contrôlable (exemple final, Phase 5) — *(Étape 15.C, 2026-09-20 : `CameraController` orbitale pilotée par l'input unifié, branchée sur l'exemple `demo`)*
### 2.2 Meshes primitifs (bibliothèque procédurale, WSGL)
- [x] Module `math::primitives` générant des `Geometry` prêts à l'emploi (positions + normales + UVs + indices) : `cube`, `plane`, `uv_sphere`, `icosphere`, `cylinder`, `cone` (et `torus` en bonus) — *(Étape 15.A, 2026-09-20 : implémenté, commit `4da89c7`)*
- [x] Factoriser le `cube_geometry` des exemples (`cube.rs`) vers `primitives::cube` — *(Étape 15.A : `cube.rs` et `spot_test.rs` utilisent désormais `math::cube(1.0)` ; `shadow_test.rs` garde son `box_geometry` générique)*
- [x] Tests unitaires : comptes de sommets/indices cohérents, normales unitaires orientées — *(6 tests dans `primitives.rs`)*
### 2.3 Input unifié (clavier / souris / gamepad, WSGL)
- [x] Module `core::input` : `InputState` à sémantique cross-frame (pressed/held/released), consommation des `WindowEvent`/`DeviceEvent` winit, souris (position, delta, boutons, molette), clavier (touches), gamepad (v1 minimale optionnelle) — *(Étape 15.B, 2026-09-20, commit `b41f7e2` : clavier/souris/molette faits ; gamepad réservé/reporté)*
- [x] Boucle dans `App::run` (`begin_frame`/`end_frame`) + exposition `app.input()` / `app.input_mut()` — *(Étape 15.B : champs publics `app.input` + rotation `begin_frame`/`end_frame` autour de `update`)*
- [x] Contrôleur caméra orbitale (`CameraController`) construit sur l'input — *(Étape 15.C, 2026-09-20, prérequis de l'exemple final `demo`)*
- [~] Gamepad (v1 minimale optionnelle) — *(reporté, DRAFT D7 ; l'API d'input s'étendra sans rupture : champ `gamepad` réservé)*
---
## Phase 3️⃣ — GPU-Driven Rendering ✅ (2026-09-22)
**Objectif** : Déléguer les calculs de transformation et culling au GPU (suivre ARCHI_CPU_GPU.md).
**Statut** : implémenté (Étape 17, validé 2026-09-22, décisions D1–D14 — référence durable : `docs/tech/ARCHI_CPU_GPU.md`, texte intégral du draft : git `3a424af`). Culling **désactivé par défaut** (non-régression), opt-in `AppBuilder::with_culling(true)`. **Correction 2026-09-22** : bug « fenêtre noire » avec culling ON (arguments de `select` WGSL écrits à la convention HLSL — toutes les entités visibles étaient remises à 0) ; corrigé et vérifié par readback GPU (D14).
### 3.1 Compute Shader
- [x] Buffer `TransformBuffer` (CPU → GPU) : positions/rotations/échelles brutes (`TransformSlot`, 64 B)
- [x] Buffer `MatrixBuffer` (GPU calculé) : World Matrices finales (`MatSlot`, `STORAGE|UNIFORM`)
- [x] Compute shader : calcul des World Matrices pour tous les meshes (`compute_matrices`)
### 3.2 Frustum Culling GPU
- [x] Ajouter `BBox` dans `Geometry` (coins min/max locaux) + `math::Frustum` (Gribb–Hartmann `[0,1]`)
- [x] Buffer `BoundingBoxBuffer` (CPU → GPU, ré-upload quand l'ensemble des meshes change, peu coûteux)
- [x] Compute shader : culling sphère vs frustum (`cull`), **désactivé par défaut** *(bug « fenêtre noire » corrigé le 2026-09-22 — ordre des arguments de `select` WGSL inversé ; cf. D14 dans `docs/tech/ARCHI_CPU_GPU.md`)*
- [x] Buffer `IndirectDrawBuffer` rempli par le GPU (`DrawSlot`, 80 B, zéro = no-op)
### 3.3 Rendu Indirect
- [x] `draw_indexed_indirect()`/`draw_indirect()` au lieu de draw calls individuels
- [x] Un draw indirect **par slot actif** (décision D1 — pas un draw fusionné unique) ; le shadow pass est aussi indirect
---
## Phase 4️⃣ — Fonctionnalités Avancées
**Objectif** : Qualité visuelle et performances.
### 4.1 Textures
- [x] Struct `Texture` avec chargement d'image *(Étape 10 : resources::Texture, from_rgba8/bytes/file, Rgba8UnormSrgb, sampler linear/repeat)*
- [x] Ajouter `uvs: Option<Vec<[f32; 2]>>` dans `Geometry` *(prérequis Étape 10, déjà présent dans le code — seul l'échantillonnage manquait)*
- [x] BindGroup pour les textures dans le shader *(Étape 10 : groupe @2 sampler+texture sur toutes les pipelines, placeholder blanc)*
- [x] `Material` supporte une texture diffuse *(Étape 10 : Material.texture + texture_bind_group, placeholder si None)*
### 4.2 Éclairage avancé
- [x] Lumières hémisphériques *(déjà dans le `standard_shader` : mélange hémisphérique, Étape 2)*
- [x] Support multi-lumières (directionnelles, ponctuelles) *(Étape 12, 2026-09-18 : liste globale dans la `Scene`, tableau `FrameUniforms.lights[8]`, shader accumule ambiant + directionnelles + ponctuelles, `MAX_LIGHTS = 8`)*
- [x] Lumières spot (cône + angle) *(Étape 13, 2026-09-18 : même struct `Light` + champ `dir_angle` (axe du cône + cos du demi-angle) + compteur `num_spot` ; boucle d'accumulation dédiée dans le shader avec pénombre lissée et atténuation linéaire ; `Scene::add_spot_light`)*
- [x] Shadows (optionnel) *(Étape 14, 2026-09-19 : shadow mapping mono-lumière — light unique
(directionnelle **ou** spot) choisie par `Scene::set_shadow_caster(index)` ; depth-only `shadow_shader.wgsl`
+ pipeline ombre dans le `Renderer` (shadow map 1024² Depth32Float, bias slope-scaled) ; pass
`render_shadow_map` en tête de `render_scene` ; PCF 3×3 + comparateur dans `standard_shader.wgsl`
(groupe @3 partagé, lié mais non échantillonné quand désactivé → non-régression). Ombres **éteintes
par défaut**. Exemple `shadow_test` : cube projetant une ombre douce sur un sol.)*
### 4.3 Optimisations
- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé)
- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated`/`generate_lod_levels` : arêtes classées par coût quadrique, repli interne −2 faces / bordure −1, weld tolérance 1e-6 **conscient des attributs** (UV ≤ ½ tuile + normales dot > 0.9 — jamais à travers une seam/côte dure), mesh sans couture reste fermé, lèvres de couture protégées sinon (surface géométriquement complète), UVs/couleurs/normales interpolés au repli (normales héritées, jamais recalculées), rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame)
- [ ] HDR + Tone Mapping (optionnel)
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
- [x] Handler `WindowEvent::Resized` dans `AppRunner::window_event` (`app.rs`) *(Étape 11, 2026-09-18)*
→ recalculer `size`, prévenir de ne pas rendre tant que la taille est invalide (0).
- [x] Reconfigurer la surface (`Context::configure`) à la nouvelle taille.
- [x] Recréer la depth texture à la nouvelle taille (`Renderer::resize_depth(width, height)`)
— le helper `create_depth_texture` isolé (Étape 9, D3) rend ce recreate trivial.
- [x] Collecte du nouveau format si la configuration change (srgb etc.) → re-valider la compat pipeline.
*(D4 : `App::resize` compare l'ancien/nouveau format et re-synchronise Renderer (`set_format`) + Scene (`init_gpu`) ; cas pathologique, structuré non exercé couramment)*
> Géré en Étape 11 (2026-09-18) : la surface est désormais reconfigurée à chaque `Resized` et la
> depth texture recréée en même temps (helper `create_depth_texture` isolé, Étape 9, D3). Le present
> mode FIFO reste figé (voir Notes de Décision).
---
## Phase 5️⃣ — Documentation & Polish
- [x] Exemple complet : mesh texturé, éclairé, avec caméra orbitale — *(Étape 15, 2026-09-20 : exemple `demo` — les 7 primitives, textures procédurales, 3 lumières, ombre portée, caméra orbitale live ; vérifié headless)*
- [x] Documentation API — *(Étape 16, 2026-07-19 : guide utilisateur `docs/user/` en français (8 pages interconnectées) + rustdoc complet sur toute l'API publique ; le `ARCHI_SCENE.md` séparé prévu est remplacé par les pages `docs/user/` + rustdoc — DRAFT Étape 16, décision D3)*
- [x] Tests unitaires : `Geometry`, `Scene`, `Transform` — *(Étape 16 : modules de tests ajoutés à `math/geometry.rs`, `math/transform.rs`, `scene/scene.rs` ; 28 tests unitaires + doctests au total, `cargo test --workspace` vert)*
- [x] README mis à jour avec les nouvelles fonctionnalités — *(Étape 16 : README racine re-ancré — workflow déclaratif = recommandé, manuel = avancé, `demo` = showcase, pollster 1.x ; README de modules `lib/src/**` à jour ; liens tech docs interconnectés)*
---
## Notes de Décision
| Décision | Raison |
|----------|--------|
| **Normals dès Phase 1** | Nécessaires pour le shader Phong ; sans elles, pas d'éclairage |
| **BBox en Phase 3** | Utile uniquement pour le frustum culling GPU |
| **World Matrix CPU → MVP, GPU → Phase 3** | Le MVP est plus simple avec un uniform par mesh ; la migration GPU-driven est progressive |
| **String IDs pour le MVP, slotmap reporté** | Le code et le README utilisent des String IDs (simples, sûrs, figés avant la boucle de rendu) ; `ARCHI_ARENES.md` reste la cible "handles typés" pour plus tard. La dépendance `slotmap` a été retirée tant qu'elle est inutilisée |
| **Present mode FIFO figé pour l'instant** | Le swapchain utilise `PresentMode::Fifo` avec `desired_maximum_frame_latency: 2` (double buffering vsync) — défaut sûr : pas de tearing, énergie minimale, zéro artefact. On **gèle ce choix** ; `Mailbox` (triple buffering) pourra être exposé en option et `Immediate` restera réservé à l'offscreen, **on s'occupera du present mode le moment venu** (quand le pipeline GPU-driven arrivera, Phase 3) — ce n'est pas bloquant pour les étapes 1-2 |
| **Resize géré (avec recréation de la depth texture), acté en D3 (2026-09-18), réalisé en Étape 11 (2026-09-18)** | L'app reconfigure désormais la surface et recrée la depth texture **en même temps** à chaque `Resized` (`App::resize` → `Context::configure` + `Renderer::resize_depth`), via le helper `create_depth_texture` isolé. Vérifié au runtime (exemple `cube`) : pas de crash, pas d'artefact, aspect correct |
| **WGSL `select(reject, accept, cond)`** | L'ordre des arguments est l'inverse de la convention HLSL : le **second** argument est retenu quand la condition est vraie. L'avoir écrit à la convention HLSL a produit le bug « fenêtre noire » du culling (comptes remis à 0 pour les entités visibles), corrigé le 2026-09-22 (D14). Piège documenté en tête de `gpu_driven.wgsl`, dans `AGENTS.md` et `docs/tech/ARCHI_CPU_GPU.md` |