# DRAFT — Étape 17 : Rendu GPU-driven (ROADMAP 3.1 / 3.2 / 3.3) > **Statut** : **implémenté et validé** (voir la section « Acceptation » plus bas). > Conventions : cette étape couvre les 3 items de la Phase 3 de `docs/ROADMAP.md`. > ROADMAP / README / docs user sont mis à jour ; ce DRAFT est conservé comme référence de > conception (les décisions D1–D14 y sont documentées). > > **Note de validation runtime (correction D12)** : la capacité est passée de **4096 à 256** > entités, et le slot de matrice mondes est **padded de 64 à 256 o**. Le buffer de matrices est > lié au slot `uniform` « object » du pipeline de rendu, et WebGPU impose **deux** contraintes : > (1) une binding `uniform` unique est plafonnée à `max_uniform_buffer_binding_size` (64 ko), et > (2) un offset de buffer `uniform` doit être un multiple de `min_uniform_buffer_offset_alignment` > (256 o). Une matrice de 64 o ne peut donc jamais être adressée individuellement par un offset > dynamique `uniform` : chaque slot de matrice est **padded à 256 o** (`MatSlot { m, pad }`), et > 256 slots × 256 o = 64 ko est le maximum adressable. Par ailleurs, le bind group « object » lie > une **slice de 64 o** (une matrice) plutôt que le buffer entier — une binding sur tout le buffer > plafonnerait l'offset dynamique à 0. Les mentions « 4096 » / « 1024 » ci-dessous renvoient au > plan initial ; la valeur réelle (et les tailles de buffer dérivées) est 256 (cf. D2 / D4 / D12). > > **Note du 2026-09-22 (D14, correction post-implémentation)** : le `demo` (culling activé) affichait > une **fenêtre noire** — readback GPU : tous les comptes de draw args étaient à 0 alors que les > transforms, bboxes et plans de frustum vus par le GPU étaient corrects. Cause racine : l'ordre des > arguments de `select` en WGSL (`select(reject, accept, cond)` renvoie le **second** argument quand > `cond` est vrai — l'inverse de la convention HLSL). Corrigé et vérifié par readback (cf. D14). ## Objectifs 1. **3.1 — Matrices mondes sur le GPU** : un compute shader dérive la matrice monde de chaque entité à partir de ses données de transform (T·R·S), au lieu d'un `to_matrix()` CPU par entité chaque frame. 2. **3.2 — Culling GPU** : un compute shader teste la visibilité de chaque entité (approximation sphère vs les 6 plans du frustum) et écrit un slot d'arguments de draw. 3. **3.3 — Draw indirect** : le rendu de la scène passe en `draw_indexed_indirect` / `draw_indirect` par slot d'entité, piloté par les arguments produits par le culling. ## Contraintes & principes - **API publique stable** : les exemples existants (cube, simple, shadow_test, spot_test, manual) restent fonctionnels **sans modification de leurs appels**. En pratique aucun n'est touché (cf. D11) ; seul `demo.rs` change (activation du culling, cf. D8). - **Aucune régression visuelle** : le culling est **désactivé par défaut** (D8). Le chemin GPU-driven (matrices + draw indirect) est actif pour `render_scene` mais produit un rendu **identique** au chemin CPU actuel. - **`standard_shader.wgsl` non modifié** (D2) : le binding group 1 reste `var object: ObjectUniform`. Seuls les commentaires en français sont traduits en anglais (convention : toute la doc est en anglais). ## Décisions (à valider) ### D1 — Un draw indirect par entité (pas de multi-instancing par groupe de mesh) Chaque entité possède son **slot GPU** (index stable `0..N`). Un draw indirect par entité lit les arguments de son slot. - ✅ Couvre les 3 items de la roadmap : matrices mondes GPU (3.1), culling GPU (3.2), draw indirect (3.3). - ✅ API publique inchangée ; les entités hétérogènes (meshs différents) restent trivialement supportées. - ❌ Coût CPU : 1 `draw_indirect` par entité (vs 1 draw multi-instanced par groupe de mesh). Pour la scale (milliers d'instances d'un même mesh), le multi-instancing par groupe est plus efficace → **reporté en Phase 4.4** (instancing / multi-instancing). - **Capacité fixe : 4096 entités** (`MAX_GPU_ENTITIES`). `Scene::add_entity` renvoie `Err` au-delà. Les slots sont **append-only avec tombstones** : la suppression ne décale pas les indices (stabilité GPU) ; l'entité est marquée inactive (flag dans le slot de transform) et le culling met ses arguments à 0. ### D2 — Le bind group « object » par entité devient une slice du buffer GPU-computé - Le binding group layout existant (group 1, `var object: ObjectUniform`, 64 o) est **conservé tel quel** (shader inchangé), mais rendu **dynamique** (`has_dynamic_offset: true`). - La valeur de 64 o vient d'une **slice de `WorldMatrixBuffer`** (storage+uniform buffer GPU-computé, **slot 256 o** padded — cf. D12) plutôt que d'un UBO CPU par entité. - **Un seul** bind group « object » partagé (`matrix_object_bg`) lie une **slice de 64 o** (une matrice) du buffer ; chaque draw l'utilise avec un **offset dynamique** `slot × 256` qui sélectionne le slot (pas un bind group par slot — un seul bind group + offset dynamique, ce qui évite N bind groups). Une slice de 64 o (et non le buffer entier) est **requise** : une binding sur tout le buffer plafonnerait l'offset dynamique à 0 (cf. D12). - Le shadow pass bénéficie automatiquement (même bind group, même offset dynamique, cf. D10). - Conséquence : les UBO d'objet CPU par entité (`object_cache`) ne sont plus utilisés par `render_scene` ; ils restent pour le chemin bas niveau `render()` (non-régression, offset 0). ### D3 — Layout des slots d'indirect draw : 80 o (pdc(16,20)) - `DrawSlot` = 80 o = 5 × `vec4`. Les 5 premiers u32 = arguments **indexed** (`index_count, instance_count, base_vertex, first_instance, instance_offset`) ; les 4 premiers u32 = arguments **non-indexed** (`vertex_count, instance_count, first_vertex, first_instance`). - Raison : l'offset du buffer indirect doit être multiple de 16 o (alignement WGSL du tableau) **et** multiple de 20 o (contrainte WebGPU pour 5 args) → pdc(16,20) = 80 o. Avec un stride de 80 o, l'offset du slot i est `80*i` (multiple de 8 et de 4 → valide pour `draw_indirect` **et** `draw_indexed_indirect`). - Le buffer est **zéro-initialisé à la création** ; le culling écrit `a` (et `b`) à chaque frame ; `c..e` restent 0 (jamais lus par le draw). ### D4 — Un seul buffer par ressource, upload CPU chaque frame (pas de double-buffering) - `TransformBuffer` (64 o × 256 = 16 ko, storage, CPU→GPU) : écrit par `queue.write_buffer` chaque frame (les transforms viennent de `Scene`, modifiés par l'utilisateur dans `update()`). - `WorldMatrixBuffer` (256 o × 256 = 64 ko, storage+uniform, GPU-write, **slots padded à 256 o** — cf. D12) : écrit par le compute « matrices » ; lu par le pipeline de rendu via le slot `uniform` « object » (64 ko = la limite `max_uniform_buffer_binding_size`, cf. D12). - `IndirectArgsBuffer` (80 o × 256 = 20 ko, storage, GPU-write) : zéro-initialisé, écrit par le compute « culling ». - `CullUniformsBuffer` (112 o, uniform, CPU→GPU) : uploadé chaque frame (6 plans + `num_slots` + `culling`). - Pas de ring-buffer pour v1 (simplicité) ; le `write_buffer` + les compute + le render sont dans **un seul CommandEncoder** → ordre garanti sur le GPU. Le ring-buffer est une optimisation possible plus tard (Phase 4.4). ### D5 — Bounding box : AABB dans `Geometry`, approximaté par une sphère pour le culling v1 - `Geometry` calcule son **AABB** (min/max des positions) à la construction ; `Mesh` le conserve (champ `bbox: BBox`). - `Scene::create_mesh` / `add_mesh` en dérive un **`BBoxSlot`** (32 o : `min: vec3f` + `max: vec3f`, coins **locaux** du box) stocké dans `BBoxBuffer` (32 o × 256 = 8 ko, storage, upload **une fois** par mesh — ré-upload seulement quand l'ensemble des meshes change, via `gpu_generation`). Le centre et les demi-extents sont **dérivés dans le shader** (`center = (min+max)/2`, `half_extents = (max-min)/2`) — le buffer ne stocke que les coins. - **Culling v1 = test sphère vs 6 plans** : le rayon GPU est `length(half_extents) * max(scale)` (demi-diagonale du box × plus grand facteur d'échelle) et le centre monde est `translation + rotation * center_local` (pas d'échelle sur le centre — l'échelle est portée par le rayon). Visible si aucun plan n'a `distance(centre, plan) < -rayon`. - Pourquoi une sphère et non l'AABB exact : l'AABB exact transformé nécessite 8 sommets + projections par axe (coût compute plus élevé, complexité WGSL) ; la sphère est **conservative** (ne culle jamais un objet visible) et suffit pour v1. L'AABB exact transformé (8 sommets, min/max par axe) est une amélioration possible (Phase 4.4). - Le **mesh_index** de chaque entité est stocké dans `flags.x` du slot de transform (rempli par le Renderer chaque frame) ; le culling lit `bboxes[transforms[i].flags.x]`. ### D6 — Plans du frustum : Gribb-Hartmann côté CPU, upload dans `CullUniforms` - Extrait les 6 plans (left, right, bottom, top, near, far) de `view * proj` (Gribb-Hartmann, adapté au clip depth [0,1] de WebGPU). - Chaque plan = `vec4` (normale + d), normalisé. - Uploadé dans `CullUniformsBuffer` (group **2** du compute, binding 0, `var`) chaque frame (layout final explicite à 3 groupes — cf. écart noté dans les critères d'acceptation : group 0 = transforms, group 1 = matrices, group 2 = culling). - Implémenté dans `math/frustum.rs` (nouveau module) avec des tests unitaires (plans de l'identité, plans d'une perspective standard, orientation des normales). ### D7 — Un shader compute `gpu_driven.wgsl` avec deux entry points - **`compute_matrices`** (`@workgroup_size(64)`) : `m[i] = T·R·S` pour `i in 0..num_slots` (T=translation, R=quaternion normalisé, S=scale → `mat4x4`). La construction WGSL reproduit `Transform::to_matrix()` (colonnes de rotation échelonnées par S, translation dans la 4e colonne). - **`cull`** (`@workgroup_size(64)`) : pour `i in 0..num_slots` : si inactive → `draws[i] = 0` ; sinon test sphère vs 6 plans → `draws[i].a = visible ? args : 0`. - Deux `ComputePipeline` créés depuis le **même module** (même layout de 3 bind groups). - `num_slots` (nombre de slots **alloués**, pas le nombre d'entités vivantes) dans `CullUniforms` → le compute parcourt tous les slots alloués (les tombstones sont marqués inactifs et produisent `draws[i] = 0`). ### D8 — Culling désactivé par défaut ; activation par `AppBuilder::with_culling(true)` - `Renderer::set_culling(bool)` + `culling_enabled()` (pub, documenté : n'affecte que `render_scene`). - `AppBuilder::with_culling(self, bool) -> Self` ; appliqué dans `resumed()` après création du Renderer. - Par défaut **OFF** (non-régression) : le pass « culling » tourne quand même (marque tout visible, `culling=0`) → le chemin indirect est toujours actif, mais rien n'est cullé. Le `demo` active le culling. - Justification : le culling est un gain de perf, pas un changement de comportement visuel ; le désactiver par défaut protège contre un bug de culling (objet qui disparaît) qui casserait les exemples. ### D9 — Slots d'entité : `Vec>` append-only + index label→slot - `Scene::entities` passe de `HashMap` à : - `entity_slots: Vec>` (append-only, tombstones) - `entity_labels: Vec>` (parallèle) - `entity_index: HashMap` (label → slot) - `entity_generation: u64` (incrémenté à chaque add/remove → le Renderer ré-uploade le mapping bbox si changé) - **`Entity` est inchangé** (pas de champ label ; le label reste dans `entity_labels`). - L'ordre d'itération est **stable** (ordre d'insertion) — important pour la cohérence des slots (le `HashMap` actuel a un ordre d'itération non-déterministe). - `add_entity` / `add_entity_with_transform` : trouvent un slot libre (premier `None` ou append si `len < MAX_GPU_ENTITIES`) ; `Err` si capacité atteinte. `remove_entity` : tombstone + dé-map de l'index. `set_entity_transform` : mise à jour in-place (pas de bump de génération — le buffer de transforms est re-uploadé chaque frame de toute façon). - `iter_entities()` : signature inchangée `(label, &Arc, &Transform)`, saute les tombstones. - `entity_count()` : nombre de slots vivants (comportement inchangé pour les tests). ### D10 — Le shadow pass est GPU-driven aussi (gratuitement) - Le pass ombre (casters) utilise les **mêmes** bind groups d'objet (slices de `WorldMatrixBuffer`) et les **mêmes** slots d'indirect → les casters sont cullés par le même pass compute. - Le shadow pass de `Renderer::render_scene` passe en draw indirect (les casters = sous-ensemble des slots ; le Renderer itère les slots et ne draw que ceux dont le mesh est caster, avec les args du slot). ### D11 — Stratégie non-régression : `render_scene` devient GPU-driven, `render` reste CPU - `Renderer::render_scene` passe de `&self` à `&mut self` (nécessaire pour `queue.write_buffer` des buffers transform/cull + mise à jour du cache de bind groups). - `App::render_scene` (qui prend déjà `&mut self`) utilise `renderer_mut()` → **aucun exemple n'appelle `Renderer::render_scene` directement** (le default `AppHandler::render` passe par `App::render_scene`) → **aucune rupture d'API pour les exemples**. - Le chemin bas niveau `Renderer::render()` (utilisé par `manual.rs`) **garde** les UBO d'objet CPU → `manual.rs` n'est pas touché. - Les 45 tests unitaires + 2 tests WGSL + 3 doctests restent verts (les tests Scene ne touchent pas le GPU ; les tests de frustum sont nouveaux). ### D12 — Capacité fixe de 256 entités, slot matrice padded à 256 o *(corrigé à la validation : initialement 4096, puis 1024)* - `MAX_ENTITIES = 256` (constante pub dans `conf.rs`). - **Pourquoi 256 (et pourquoi le slot matrice est padded à 256 o)** : le buffer de matrices mondes est lié au slot `uniform` « object » (group 1) du pipeline de rendu, et WebGPU impose **deux** contraintes : 1. une binding `uniform` unique est plafonnée à `max_uniform_buffer_binding_size` (64 ko) ; 2. un offset de buffer `uniform` (dynamique **ou** statique) doit être un multiple de `min_uniform_buffer_offset_alignment` (256 o). Une matrice de 64 o ne peut donc jamais être adressée individuellement par un offset `uniform` (son offset serait `slot × 64`, pas multiple de 256). La solution : **padded chaque slot de matrice à 256 o** (`MatSlot { m: mat4x4f, pad: array }` = 64 + 192 o). Avec des slots de 256 o, l'offset du slot i est `i × 256` (toujours aligné), et 256 slots × 256 o = 64 ko est le maximum adressable par une binding `uniform` unique. (Les buffers transforms / bboxes / indirect sont des bindings `storage` — limite 128 Mo, pas de règle d'offset 256 o — donc ils gardent leurs tailles naturelles 64 / 32 / 80 o.) - **Bind group « object » sur une slice de 64 o** : le bind group `matrix_object_bg` lie une **slice de 64 o** (une matrice) du buffer, **pas** le buffer entier — une binding sur tout le buffer (65536 o) plafonnerait l'offset dynamique à 0 (le binding couvrirait déjà tout le buffer). Avec une slice de 64 o, l'offset dynamique peut glisser jusqu'à `65536 − 64`. - Justification mémoire : 16 ko (transforms) + 64 ko (matrices) + 8 ko (bboxes) + 20 ko (indirect) ≈ 108 ko total — négligeable. Le compute dispatche 256 threads (4 workgroups de 64) — trivial. - 256 est largement suffisant pour le scope du projet (le demo a 7 entités). Pour aller au-delà, il faudrait chunker le buffer de matrices en tranches ≤ 64 ko (≤ 256 slots chacune) avec une binding `uniform` par tranche (reporté — hors scope v1). ### D13 — Buffers de culling séparés, pas d'impact sur le layout de rendu - `CullUniformsBuffer` (112 o) est dédié au compute (group **2** du compute, binding 0) ; il n'apparaît **pas** dans le layout des pipelines de rendu → le layout de rendu (groups 0..3) est inchangé. - Le `BBoxBuffer` (8 ko) est group 2 du compute (binding 1, avec `draws` au binding 2) → pas dans le layout de rendu. Le group 0 (transforms, storage read) est **partagé** par les deux entry points. ### D14 — Incident « fenêtre noire » : l'ordre des arguments de `select` en WGSL (2026-09-22) - **Symptôme** : le `demo` (culling activé, D8) affichait une **fenêtre noire**. Readback GPU (`debug_dump`) : les 7 slots de draw args avaient un compte de sommets à **0** (`.a.x = 0`) alors que `culling = 1`, `num_slots = 7`, et que les entrées vues par le GPU (transforms, bboxes, plans du frustum) étaient corrects — le test sphère simulé **côté CPU** passait pour les 7 entités (distances de plans toutes ≥ +2.06), ce qui rendait l'échec inexplicable côté données. - **Cause racine** : la convention des arguments de `select` en WGSL est **`select(reject, accept, cond)`** — renvoie le **second** argument quand `cond` est vrai, le premier quand il est faux : **l'inverse de la convention HLSL** (`select(trueVal, falseVal, cond)`) sur laquelle le pass `cull` avait été écrit. `select(u32(t.flags.z), 0u, visible)` mettait donc le compte à 0 pour **toute entité visible** (et aurait dessiné les entités cullées) : inversion silencieuse de la visibilité, sans aucune erreur de validation wgpu ni de log driver. - **Preuves** : (1) instrumentation par slot du pass (distances de plans toutes positives, flag `visible` à 0 — contradiction pure) ; (2) sonde constante `select(2.0, 3.0, true)` → **3.0** sur le GPU (NVIDIA, Vulkan) ; (3) source de **naga 30** (le compilateur WGSL de wgpu) : le frontend mappe `arg0 → reject`, `arg1 → accept`, et les backends SPIR-V/GLSL émettent `cond ? accept : reject` → conforme à la spec WGSL, **le driver n'est pas en cause** : c'est une erreur d'API WGSL. - **Correction** : `select(0u, u32(t.flags.z), visible)` (visible ⇒ compte plein, cullé ⇒ 0) + commentaire GOTCHA en tête de `gpu_driven.wgsl` + piège documenté dans `AGENTS.md`. - **Vérification** : readback après correction — les 7 entités du `demo` reprennent leurs comptes pleins (6/36/3840/960/384/192/2304, égaux aux `flags.z` packés côté CPU) ; une entité ajoutée hors frustum (z = 60, au-delà du plan lointain) est **cullée à 0** ; les slots ≥ `num_slots` restent à 0. `cargo build --workspace` sans avertissement, `cargo test --workspace` vert (63 tests). - **Leçon** : ne pas suspecter le driver avant d'avoir vérifié les conventions d'arguments des builtins WGSL ; pour ce shader, la vérité de référence est le **readback des slots** (`debug_dump`, opt-in `WSG_DEBUG_DUMP=1` dans le `demo`), pas l'image à l'écran. ## Pipeline par frame (un seul CommandEncoder) ``` [CPU] write_buffer TransformBuffer (64 o × num_slots, depuis Scene) [CPU] write_buffer CullUniforms (6 plans + num_slots + culling) [CPU] (si gpu_generation changée) write_buffer BBoxBuffer [Compute 1] compute_matrices : m[i] = T·R·S (i in 0..num_slots) [Compute 2] cull : draws[i] = visible ? args : 0 (sphère vs 6 plans) [Render pass ombre] (si ombres activées) : draw_indexed_indirect par caster (slice mat + args slot) [Render pass main] : draw_indexed_indirect / draw_indirect par slot vivant [Queue] submit(encoder) ``` Même encoder → exécution séquentielle garantie (write → compute → render). ## Layouts GPU (WGSL) ```wgsl struct TransformSlot { translation: vec3f, flags: vec4f, rotation: vec4f, scale: vec3f } // 64 o // flags.x = mesh_index (index dans BBoxBuffer), flags.y = active (1/0), // flags.z = draw count (compte de sommets/indices du mesh), flags.w = has_index (1/0) struct MatSlot { m: mat4x4, pad: array, 12> } // 256 o (padded : offset `uniform` 256-aligné) struct BBoxSlot { min: vec3f, max: vec3f } // 32 o (coins locaux ; centre/demi-extents dérivés dans le shader) struct DrawSlot { a: vec4, b: vec4, c: vec4, d: vec4, e: vec4 } // 80 o struct CullUniforms { planes: array, 6>, num_slots: u32, culling: u32, _pad: vec2 // 112 o } ``` Alignements vérifiés : stride de tableau = max(alignment) arrondi à 16 → 64 / 64 / 32 / 80 (tous multiples de 16). `CullUniforms` : 6×16 + 4 + 4 + 8 = 112 (multiple de 16). Côté Rust (`uniform.rs`, `#[repr(C)]` + bytemuck `Pod`/`Zeroable`) : même taille, avec `#[repr(align(16))]` sur les structs contenant un tableau de `vec4` pour matcher l'alignement GPU. Static asserts `size_of == 64/64/32/80/112`. ## Fichiers touchés | Fichier | Action | |---------|--------| | `lib/assets/shaders/gpu_driven.wgsl` | **Nouveau** — compute `compute_matrices` + `cull` | | `lib/src/math/frustum.rs` | **Nouveau** — extraction des 6 plans (Gribb-Hartmann [0,1]) + tests | | `lib/src/math/geometry.rs` | + struct `BBox` + `Geometry::bbox()` + tests | | `lib/src/math/mod.rs` | + `pub mod frustum;` | | `lib/src/resources/uniform.rs` | + `TransformSlot`, `BBoxSlot`, `CullUniforms` (Pod) + constantes `MAX_GPU_ENTITIES`, `MAX_MESH_BBOXES` + tailles de slots | | `lib/src/resources/mesh.rs` | + champ `bbox: BBox` (calculé dans `from_geometry`) + accès `mesh.bbox()` | | `lib/src/scene/scene.rs` | `entities` → slots append-only + index + génération ; `create_mesh`/`add_mesh` capturent le bbox ; + `iter_entity_slots()`, `gpu_generation()`, `gpu_bbox_slots()` | | `lib/src/core/renderer.rs` | + 4 buffers GPU + buffer cull-uniforms + 2 pipelines compute + layout compute ; `render_scene` → `&mut self`, passe indirect ; + `set_culling`/`culling_enabled` ; shadow pass indirect | | `lib/src/pipeline/pipeline_cache.rs` | + `create_compute_pipelines()` (module gpu_driven, 2 entry points) + `create_compute_bind_group_layout()` | | `lib/src/app.rs` | + `AppBuilder::with_culling(bool)` + champ `culling` ; `resumed()` applique `renderer.set_culling` | | `lib/src/utils/conf.rs` | + fallback embarqué `include_str!` de `gpu_driven.wgsl` (comme standard/shadow) | | `lib/examples/demo.rs` | + `AppBuilder::with_culling(true)` (démo du culling) | | `lib/tests/wgsl_validate.rs` | + test `gpu_driven_shader_is_valid_wgsl` | | `lib/src/assets/shaders/standard_shader.wgsl`, `shadow_shader.wgsl` | Commentaires FR → EN (convention doc anglaise) | | `docs/user/gpu-driven.md` | **Nouveau** — doc user (activation du culling, limites, comportement) | | `docs/user/README.md` | + lien vers `gpu-driven.md` | | `README.md` | + ligne « rendu GPU-driven (indirect draw, culling GPU) » dans les features | | `docs/ROADMAP.md` | Coche 3.1, 3.2, 3.3 | | `docs/PLAN.md` | + ligne Étape 17 | ## Périmètre exclus (reportés) - **Multi-instancing par groupe de mesh** (Phase 4.4) — 1 draw par groupe de mesh identique. - **AABB exact transformé** (8 sommets) — v1 utilise une sphère conservative. - **Ring-buffering** des buffers GPU (D4) — v1 fait un `write_buffer` par frame. - **Occlusion culling**, **LOD**, **batching par matériau** (Phase 4.3 / 4.4). - **Multi-caméras** (2.1 restant). ## Risques & mitigations | Risque | Mitigation | |--------|-----------| | **Alignement indirect draw** (offset multiple de 16/20/8/4 o) | D3 : slot 80 o = pdc(16,20), multiple de 8 et 4 → valide pour les 2 variants de draw. | | **Buffer undefined à la création** | `IndirectArgsBuffer` zéro-initialisé (`create_buffer_init` zéros) → `c..e` restent 0. | | **Ordre compute → render** | Même `CommandEncoder` → séquentiel. `write_buffer` + compute + render dans le même encoder. | | **Bind groups par slot : mémoire** | 4096 bind groups ≈ trivial (état driver partagé via le layout). | | **Bug de culling (objet qui disparaît)** | D8 : OFF par défaut ; le `demo` l'active → bug visible immédiatement. Sphère conservative (D5). **Réalisé en 2026-09-22 (D14)** : le bug s'est produit (fenêtre noire) et a été traçable grâce à cette mitigation + readback. | | **Builtins WGSL aux conventions d'arguments non intuitives** (ex. `select`, l'inverse de HLSL) | D14 : piège documenté en tête du shader + dans `AGENTS.md` ; diagnostic par **readback des slots** (`debug_dump`) plutôt que par l'image seule. | | **Ordre non-déterministe des entités** | D9 : `Vec` append-only (stable) remplace le `HashMap` (ordre aléatoire). | | **`render_scene` → `&mut self`** | Aucun exemple n'appelle `Renderer::render_scene` directement (cf. D11) → non-régression. | | **WGSL compute non validé** | Test `wgsl_validate` sur le nouveau shader + `cargo build` (wgpu compile à l'exécution). | | **Shadow pass indirect** | Les casters utilisent les mêmes slots d'args → cohérent ; testé dans le `demo` (ombres + culling actifs). | | **Alignement WGSL ≠ Rust** | `#[repr(align(16))]` + static asserts de taille sur chaque struct Pod (cf. section « Layouts GPU »). | ## Plan de vérification 1. `cargo build --workspace` — OK. 2. `cargo test --workspace` — 45 tests unitaires + **nouveaux tests frustum/geometry** + 2→3 tests WGSL + 3 doctests, tous verts. 3. `cargo fmt --all -- --check` — clean. 4. **`demo` en headless** : `WGPU_BACKEND=vulkan timeout 10 cargo run -p examples --example demo` → exit 0, **culling actif** (`with_culling(true)`), pas d'objet qui disparaît. 5. **Tous les autres exemples** (cube, simple, shadow_test, spot_test) en headless → exit 0, **rendu identique** (culling OFF par défaut, chemin indirect actif). 6. **`manual.rs`** en headless → exit 0 (chemin `render()` bas niveau inchangé, UBO CPU). 7. **Check liens** : 0 lien cassé (nouveaux `docs/user/gpu-driven.md` + liens README). 8. **Aucun caractère accentué** dans les fichiers modifiés (sauf `docs/tech/`, DRAFT, PLAN, ROADMAP, DOCUMENTATION) — les commentaires WGSL traduits. 9. **WGSL valide** : le nouveau `gpu_driven.wgsl` compile (test `wgsl_validate`). ## Critères d'acceptation (definition of done) > **Implémenté le 2026-07-20.** Tous les critères sont remplis (avec les écarts de nommage > notés ci-dessous). `cargo build`/`cargo test --workspace` : 56 lib + 3 WGSL + 3 doctests verts, > clippy sans avertissement dans `renderer.rs`. - [x] `compute_matrices` + `cull` dans `gpu_driven.wgsl` (2 entry points, 1 module). *Écart : layout compute explicite à 3 groupes partagé par les 2 pipelines (évite les gaps de groupes du layout inféré) — cf. D11.* - [x] 4 buffers GPU (transform, matrices, bboxes, draw-args) + 1 buffer cull-uniforms dans le `Renderer`. - [x] `render_scene` 100 % indirect (main + shadow). *Écart : `render_scene` reste `&self` (buffers persistants créés dans `new()` + `Cell` pour le culling) — plus simple que le `&mut self` prévu, et sans ré-allocation par frame.* - [x] `Scene` slots append-only + tombstones (indices stables). *Écart de nommage : `packed_transform_slots()` / `iter_slot_draws()` / `mesh_bboxes()` / `mesh_index_of()` / `mesh_by_index()` / `num_slots()` / `num_active_slots()` (plutôt que `iter_entity_slots` / `gpu_generation` / `gpu_bbox_slots`).* - [x] `Geometry::bbox()` + `BBoxSlot` ; upload du buffer de bboxes à chaque frame (peu coûteux, toujours correct si des meshes sont ajoutés). - [x] `math/frustum.rs` (Gribb-Hartmann [0,1]) + 5 tests. - [x] `AppBuilder::with_culling(bool)` + `Renderer::set_culling` (OFF par défaut). - [x] `demo` active le culling (`with_culling(true)`) ; `cargo build --workspace` vert. - [x] Commentaires WGSL EN ; aucun accent dans les fichiers de code touchés. - [x] Docs user (`docs/user/gpu-driven.md`), README, ROADMAP (3.1/3.2/3.3 cochés). - [ ] DRAFT.md vidé après validation utilisateur (conservé en référence pour l'instant). --- **Validation** : les décisions D1–D14 sont implémentées (D1 = draw indirect par entité, 256 slots, culling OFF par défaut). Le bug « fenêtre noire » (D14) est **corrigé et vérifié par readback GPU** le 2026-09-22 (comptes pleins pour les entités visibles, compte 0 pour l'entité hors frustum, slots ≥ `num_slots` à 0 ; 63 tests verts). Il reste à confirmer le rendu **visuel** du `demo` avec culling ON, puis autoriser le vidage de ce DRAFT.