material batching

This commit is contained in:
Jérôme Bousquié
2026-09-22 17:07:52 +02:00
parent 3a424afe8c
commit 531c43a457
9 changed files with 313 additions and 407 deletions
+149 -363
View File
@@ -1,404 +1,190 @@
# DRAFT — Étape 17 : Rendu GPU-driven (ROADMAP 3.1 / 3.2 / 3.3)
# DRAFT — Étape 18 : Batching par Material (ROADMAP 4.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).
> **Statut** : brouillon de conception (à valider avant implémentation).
> Couvre le 1er item de la Phase 4.3 de `docs/ROADMAP.md` :
> « Batching par Material (réduction des state changes GPU) ».
>
> **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).
> **Archive** : le draft Étape 17 (rendu GPU-driven) a été vidé après validation (2026-09-22).
> Référence durable : `docs/tech/ARCHI_CPU_GPU.md` (écarts D1/D4/D5/D12 + piège D14) ;
> texte intégral : git `3a424af` (`git show 3a424af:docs/DRAFT.md`).
## Objectifs
## Contexte — où on en est
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.
Le rendu est 100 % indirect (Étape 17) : un draw indirect **par slot**, dans l'ordre d'insertion
des entités. À chaque draw, la passe principale met à jour (`Renderer::render_scene`,
`renderer.rs` §7) :
| Appel | Coût |
|---|---|
| `set_pipeline(material.pipeline)` | **changement d'état** (swap de pipeline côté driver) |
| `set_bind_group(0, frame)` | constant dans la passe (re-set inutile mais pas cher) |
| `set_bind_group(1, object, [offset dynamique])` | paramètre de draw — **pas** un changement d'état |
| `set_bind_group(2, material.texture_bind_group)` | **changement d'état** (un bind group par `Material`) |
| `set_bind_group(3, shadow)` | constant |
| `set_vertex_buffer` / `set_index_buffer` | par mesh, pas cher |
| `draw_*_indirect` | le draw lui-même |
→ Le nombre de changements d'état (pipeline + bind group @2) est proportionnel au **nombre
d'entités**, même quand des dizaines d'entités partagent le même `Material`. La doc du module
promet déjà « Entity sorting ... minimizes pipeline switches (batching by material) » — c'est ce
que fait cette étape.
Le pass d'ombre n'a qu'**un** pipeline et l'appelle **une seule fois** avant la boucle
(`renderer.rs` §5) → déjà « batché » sur l'état : rien à y faire.
## Objectif
Réduire les changements d'état de la passe principale de **O(entités)** à **O(matériaux
distincts)**, sans changer le rendu, sans changer l'API publique, et sans toucher au chemin
GPU-driven (compute, culling, buffers, indirect) ni au pass d'ombre.
## 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<uniform> object: ObjectUniform`. Seuls les commentaires en français sont traduits en
anglais (convention : toute la doc est en anglais).
- **Aucune rupture d'API** : optimisation interne du `Renderer` ; aucun type/méthode publique
nouveau ; les exemples ne sont pas modifiés.
- **Aucune régression visuelle** : tous les pipelines de l'engine sont **opaques**
(`BlendState::REPLACE`, `pipeline_cache.rs`) → le depth buffer résout l'ordre → réordonner
les draws est visuellement neutre (D4).
- **Culling/indirect intacts** : les verdicts GPU (args = 0 → no-op) et les buffers ne changent
pas ; seul l'**ordre d'émission** des draws change.
- **Déterminisme** : l'ordre des draws doit rester reproductible frame après frame (slots
append-only stables, Étape 17 D9).
- Capacité ≤ 256 slots → le groupage (O(N), `HashMap` ≤ 256 entrées) est du bruit ; on le
refait **chaque frame** (toujours correct, aucun cache à invalider).
## Décisions (à valider)
## Décisions
### D1 — Un draw indirect par entité (pas de multi-instancing par groupe de mesh)
### D1 — Clé de groupage : l'identité du `Material` (pointeur `Arc`), pas le shader_id
- Le vrai « état » qui change entre deux draws partageant un pipeline est le **bind group @2**
(texture/sampler) : chaque `Material` en possède un propre (`Material::build`). Deux
materials qui partagent le même `shader_id` partagent le pipeline (Arc, `PipelineCache`)
mais **pas** le bind group @2 → grouper au niveau pipeline ferait alterner le @2.
- Clé retenue : `Arc::as_ptr(&material)` — même pointeur ⟺ même objet `Material` ⟺ même
pipeline **et** même bind group @2 → les deux changements d'état sont figés dans le groupe.
- Le fallback `scene.default_material()` est un `Arc` unique en cache (`RefCell`) → toutes les
entités sans material forment un groupe.
- **Subtilité de durée de vie** : le groupage matérialise d'abord les `Arc<Material>` dans un
`Vec` (un par slot actif) ; les pointeurs-clés ne sont dérivés qu'ensuite. Les `Arc` restent
donc vivants pendant toute la passe → aucun pointeur ne pend (le `Arc` retourné par
`default_material()` est un clone : sans le `Vec`, il serait libéré en fin de closure).
Chaque entité possède son **slot GPU** (index stable `0..N`). Un draw indirect par entité lit
les arguments de son slot.
### D2 — Ordre des groupes : première apparition en ordre de slots ; intra-groupe : ordre de slots
- On parcourt les slots dans leur ordre stable (insertion, Étape 17 D9) ; le groupe d'un slot
est créé à la **première** apparition de sa clé. `HashMap<clé, index>` + `Vec<groupe>` :
O(N), sans tri, **déterministe** et stable frame à frame (scène inchangée → ordre inchangé).
- En pratique, le premier draw de chaque groupe est le même que l'ancien premier draw du slot
→ l'impression visuelle est préservée ; seuls les draws de matériaux *différents*
s'intercalent moins.
- ✅ 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.
### D3 — Les slots cullés (no-op) restent émis dans leur groupe
- Le CPU ne connaît pas le verdict GPU du culling (un readback par frame stallerait la boucle
— cf. `debug_dump`) : le draw d'un slot cullé est un indirect **zéro count ≈ gratuit** ; on
l'émet quand même, dans son groupe.
- Le nombre de **draw calls** est donc inchangé (1 par slot actif) ; seul le nombre de
**changements d'état** baisse. Réduire aussi les draw calls = multi-instancing (exclu —
Périmètre).
### D2 — Le bind group « object » par entité devient une slice du buffer GPU-computé
### D4 — Réordonnancement sûr : pipelines 100 % opaques
- `pipeline_cache.rs` crée toutes les pipelines avec `blend: Some(BlendState::REPLACE)`
(aucun alpha blending dans l'engine) et le depth write est actif partout (Étape 9) →
l'ordre de rasterisation n'a pas d'impact visuel.
- **Contrainte à documenter** (docs user + rustdoc) : si du blending transparent est ajouté un
jour, il faudra isoler les matériaux transparents (trier back-to-front en fin de passe) —
signalé ici comme prérequis d'un futur `Material.blend`. Le groupage par Material reste
correct en l'état ; seule l'ordre inter-groupes devra évoluer.
- Le binding group layout existant (group 1, `var<uniform> 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).
### D5 — Groupage en fonction pure, testable sans GPU
- Le groupage est factorisé en fonction libre :
`fn batch_slots<K: Eq + Hash>(keys: &[K]) -> Vec<Vec<usize>>`
(groupes dans l'ordre de première apparition de la clé ; indices dans l'ordre d'entrée ;
chaque indice apparaît exactement une fois).
- Le `Renderer` l'appelle avec `keys = [*const Material]` (D1) ; les **tests unitaires**
l'appellent avec des clés `u32` → testable sans instance/device wgpu (un `Material` exige
un pipeline compilé = device ; les tests de la crate restent headless/CI-safe).
- Les tombstones (`active == false`) sont filtrés **avant** l'appel (comme aujourd'hui) :
`batch_slots` ne voit que les slots actifs.
### D3 — Layout des slots d'indirect draw : 80 o (pdc(16,20))
### D6 — Pass d'ombre : inchangé (déjà batché)
- Un seul `shadow_pipeline`, `set_pipeline` une seule fois avant la boucle ; l'état résiduel
par slot (offset dynamique @1 + vertex/index buffers) n'est pas un changement d'état
driver → `render_shadow_map` n'est pas modifié par cette étape.
- `DrawSlot` = 80 o = 5 × `vec4<u32>`. 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<f32>` (normale + d), normalisé.
- Uploadé dans `CullUniformsBuffer` (group **2** du compute, binding 0, `var<uniform>`) 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<Option<Entity>>` append-only + index label→slot
- `Scene::entities` passe de `HashMap<String, Entity>` à :
- `entity_slots: Vec<Option<Entity>>` (append-only, tombstones)
- `entity_labels: Vec<Option<String>>` (parallèle)
- `entity_index: HashMap<String, usize>` (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<Mesh>, &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<vec4f,12> }` = 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)
## Mécanisme par frame (seul le point 7 change)
```
[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)
[CPU] write_buffer TransformBuffer / CullUniforms / (BBoxBuffer si génération changée)
[Compute 1] compute_matrices [Compute 2] cull (inchangés)
[Ombre] (si caster) draw indirect par slot, 1 pipeline (inchangé — D6)
[Main] slots groupés par Material (D1/D2) :
groups = batch_slots(keys)
pour chaque groupe G :
set_pipeline(G) + set_bind_group(0) + set_bind_group(2, G) + set_bind_group(3)
pour chaque slot s de G :
set_bind_group(1, [offset(s)]) + set_vertex/set_index + draw_indirect(s)
[Queue] submit
```
Même encoder → exécution séquentielle garantie (write → compute → render).
## Gain attendu (changements d'état / frame, passe principale)
## Layouts GPU (WGSL)
| Scène | Avant (par entité) | Après (par matériau distinct) |
|---|---|---|
| `demo` (7 entités, ~5 materials distincts) | 7 × (pipeline + @2) | 5 × (pipeline + @2) |
| 200 cubes / 1 material | 200 × (pipeline + @2) | **1** × (pipeline + @2) |
```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<f32>, pad: array<vec4<f32>, 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<u32>, b: vec4<u32>, c: vec4<u32>, d: vec4<u32>, e: vec4<u32> } // 80 o
struct CullUniforms {
planes: array<vec4<f32>, 6>, num_slots: u32, culling: u32, _pad: vec2<u32> // 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`.
Le `set_bind_group(1, offset dynamique)` et les vertex/index buffers restent par draw
(paramètres de draw, pas d'état driver). Le gain est maximal quand peu de matériaux distincts
pour beaucoup d'entités — le cas « instancé » en attendant le multi-instancing.
## 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 |
| `lib/src/core/renderer.rs` | `render_scene` : boucle plate → `batch_slots` + boucle par groupe ; fonction libre `batch_slots` + tests unitaires ; rustdoc du module alignée |
| `docs/user/gpu-driven.md` | + note : draws groupés par matériau (interne, sans effet API) ; contrainte blending (D4) |
| `docs/tech/ARCHI_CPU_GPU.md` | + note : la passe main émet les draws groupés par Material (Étape 18) |
| `docs/ROADMAP.md` | Coche « Batching par Material » (4.3) + date |
## 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).
- **Multi-instancing** (1 draw par groupe mesh+material, matrice par instance) : demande un
changement de shader (matrice instanciée) + draw instancié par groupe — étape distincte,
plus lourde (déjà reportée depuis l'Étape 17).
- **LOD, HDR + tone mapping** (items 2-3 de la Phase 4.3) : étapes distinctes.
- **Sauter les no-ops par readback** (réduire les draw calls, pas seulement les états) : un
readback synchro stalle la boucle de rendu → rejeté en v1.
- **Blending/transparent** : hors engine actuel (D4).
## 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 »). |
| **Réordonnancement → rendu différent** | D4 : pipelines opaques (`REPLACE`) + depth write → le depth buffer résout l'ordre ; vérifié headless + visuellement (plan 4-5), draw args `debug_dump` inchangés (plan 6). |
| **Pointeur-clé `Arc::as_ptr` en pend** | D1 : les `Arc` sont matérialisés dans un `Vec` vivant pendant la passe ; les groupes reconstruits chaque frame → aucune hypothèse de stabilité entre frames. |
| **Groupage O(N) par frame** | N ≤ 256, `HashMap` ≤ 256 entrées → bruit ; pas de cache (D5 : toujours correct). |
| **Ordre des groupes non déterministe** | D2 : première apparition sur des slots stables (Étape 17 D9) → déterministe ; verrouillé par test (D5). |
| **Matériau transparent futur** | D4 documenté comme contrainte ; le groupage par Material reste correct, seule l'ordre inter-groupes devra évoluer. |
## 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.
1. `cargo build --workspace` — OK, sans avertissement.
2. `cargo test --workspace` — vert, y compris les nouveaux tests `batch_slots` (première
apparition, ordre intra-groupe, chaque indice une fois, entrées vides, un seul groupe,
tout distinct).
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`).
4. **`demo` headless** (`WGPU_BACKEND=vulkan timeout 10 ... --example demo`) → exit 0.
5. **A/B changements d'état** : compteur temporaire de `set_pipeline` par frame (sous
`WSG_DEBUG_DUMP`) — avant : 7 dans le `demo` ; après : nombre de materials distincts.
(Compteur retiré après mesure, ou conservé sous `WSG_DEBUG_DUMP` au choix.)
6. **Rendu identique** : `demo` avant/après → même image (opaque, D4) ; `debug_dump` :
draw args inchangés (le culling n'est pas touché).
7. Check liens doc — 0 lien cassé.
## 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<bool>`
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.
- [x] `render_scene` émet les draws **groupés par Material** (D1/D2) ; pass d'ombre inchangé (D6).
- [x] `batch_slots` fonction pure testable (D5) + tests unitaires verts (6 tests, CI-safe).
- [x] Aucune rupture d'API publique ; exemples non modifiés.
- [x] Rendu identique (D4) — vérifié headless ; draw args `debug_dump` inchangés (6/36/3840/960/384/192/2304).
- [x] Changements d'état réduits : compteur `set_pipeline` du `demo` = 3 = nb de materials distincts (avant : 7).
- [x] Docs : `gpu-driven.md` § « Batching by material » + `ARCHI_CPU_GPU.md` + ROADMAP 4.3 coché.
- [ ] DRAFT.md vidé après validation utilisateur (convention de la maison).
+5 -5
View File
@@ -117,10 +117,10 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
---
## Phase 3️⃣ — GPU-Driven Rendering ✅ (2026-07-20)
## 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é (DRAFT Étape 17, décisions D1–D14). 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 (DRAFT D14).
**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)
@@ -130,7 +130,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
### 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. DRAFT D14)*
- [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
@@ -161,7 +161,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
par défaut**. Exemple `shadow_test` : cube projetant une ombre douce sur un sol.)*
### 4.3 Optimisations
- [ ] Batching par Material (réduction des state changes GPU)
- [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é)
- [ ] Level of Detail (LOD)
- [ ] HDR + Tone Mapping (optionnel)
@@ -199,4 +199,4 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
| **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 (DRAFT D14). Piège documenté en tête de `gpu_driven.wgsl` + `AGENTS.md` |
| **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` |
+2 -2
View File
@@ -16,7 +16,7 @@ stale_after: 2027-01-31
wsg_lib est un moteur de rendu modulaire basé sur wgpu. Il adopte une architecture à deux niveaux : une façade de haut niveau pour la productivité et un accès bas niveau pour un contrôle total.
> **État du document : ACTUEL** — façade (`App`/`AppHandler`, §3, §4A) et pipeline GPU-driven
> (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (2026-07-20, DRAFT Étape 17, décisions
> (§1, §4B, §5, §6) **implémenté en Phase 3** du ROADMAP (Étape 17, 2026-09-22, décisions
> D1–D14). La façade `AppBuilder`/`App`/`AppHandler` est livrée et est le **workflow recommandé** :
> `setup` (déclaration de la scène) → par frame `update` (mutation) →
> `render` (défaut : `App::render_scene` = itération des entités + **rendu groupé en une passe**,
@@ -25,7 +25,7 @@ wsg_lib est un moteur de rendu modulaire basé sur wgpu. Il adopte une architect
> ombres, caméra orbitale, culling GPU activé). Le workflow **manuel** (exemple `manual`) coexiste
> pour le contrôle fin.
> Les sections §1, §4B, §5 et §6 décrivent le pipeline GPU-driven **tel qu'implémenté**, avec les
> écarts documentés (DRAFT Étape 17) : un draw indirect par slot (D1), table fixe de 256 slots
> écarts documentés (cf. `ARCHI_CPU_GPU.md`) : un draw indirect par slot (D1), table fixe de 256 slots
> (D12), culling par sphère conservative (D5), single buffer (D4), et le piège de l'ordre des
> arguments de `select` en WGSL (D14, bug « fenêtre noire » corrigé le 2026-09-22). La section
> « Notes pour l'implémentation future » (double buffering) reste **CIBLE**.
+10 -2
View File
@@ -16,19 +16,27 @@ Bonnes Pratiques & Guide d'Implémentation
Ce document sert de spécification technique et de trame d'implémentation pour l'architecture de rendu 3D pilotée par le GPU (GPU-Driven Rendering) utilisant wgpu. L'objectif est de déléguer un maximum de charges de calcul au GPU pour soulager le CPU et maximiser les performances de parallélisme.
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, 2026-07-20, DRAFT Étape 17).**
> **État du document : ACTUEL (implémenté — Phase 3 du ROADMAP, Étape 17, validé 2026-09-22).**
> La répartition CPU/GPU, le compute pass (World Matrices + Frustum Culling), l'Indirect Draw Buffer
> et les buffers persistants en VRAM décrits ici sont en place : `shaders/gpu_driven.wgsl`
> (deux entry points `compute_matrices` + `cull`, un module, layout explicite à 3 groupes) et les
> buffers de slots du `Renderer` (`TransformSlot`/`MatSlot`/`BBoxSlot`/`DrawSlot`/`CullUniforms`,
> capacité fixe de 256 slots).
> **Écarts documentés** (cf. DRAFT Étape 17) : (D1) un draw indirect **par slot** plutôt qu'une
> Ce document est la **référence durable** de la conception : le draft d'origine de l'Étape 17
> (décisions D1–D14, layouts, plan de validation) a été vidé de `docs/DRAFT.md` après validation
> et vit dans le git (`git show 3a424af:docs/DRAFT.md`) ; l'essentiel en est repris ci-dessous.
> **Écarts documentés** (numérotation du draft d'origine) : (D1) un draw indirect **par slot** plutôt qu'une
> commande unique fusionnée ; (D12) 256 slots, slot matrice padded à 256 o (plafond `uniform` WebGPU) ;
> (D5) culling par **sphère** conservative dérivée de l'AABB locale du mesh, pas par l'AABB transformée
> exacte ; (D4) single buffer, pas de double-buffering.
> **Piège connu (2026-09-22, D14)** : l'ordre des arguments de `select` en WGSL est l'inverse de la
> convention HLSL — l'avoir inversé a produit un bug « fenêtre noire » (entités visibles remises à 0),
> corrigé et vérifié par readback GPU. Documenté en tête de `gpu_driven.wgsl` et dans `AGENTS.md`.
> **Batching par material (Étape 18, 2026-09-22)** : la passe principale émet désormais les draws
> groupés par `Material` (1 `set_pipeline` + 1 bind group @2 par matériau distinct, pas par entité ;
> le pass d'ombre — un seul pipeline — est inchangé). Réordonnancement sûr car tous les pipelines
> sont opaques (`BlendState::REPLACE`) ; les no-ops cullés restent émis dans leur groupe.
> Détail : `docs/user/gpu-driven.md` § « Batching by material ».
1. Répartition des Rôles : CPU vs GPU (La Source de Vérité)
+1 -1
View File
@@ -68,7 +68,7 @@ Avec notre nouvelle architecture "Atelier", la distinction est devenue encore pl
| CommandEncoder | Par-Frame | Ton "carnet de notes" temporaire pour les ordres du GPU. |
| TextureView | Par-Frame | Fenêtre temporaire sur la texture active du swapchain. |
> **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-07-20)** : les buffers
> **Ressources GPU persistantes (single buffer) — implémenté (Phase 3, 2026-09-22)** : les buffers
> Transform, Matrix, BBox et Indirect Draw vivent en VRAM (créés à l'initialisation du `Renderer`,
> capacité fixe de 256 slots). Le CPU écrit les transforms chaque frame par `queue.write_buffer`
> **dans le même `CommandEncoder`** que les compute passes, qui les lisent **dans la même frame**
+15 -1
View File
@@ -22,6 +22,20 @@ each frame — it never iterates the entities to issue draws.
You do not need to do anything special to get this: `render_scene` is GPU-driven by default.
## Batching by material
The main render pass batches the draws by material: all entities sharing the same material are
drawn back to back, so the GPU pipeline and the material's texture bind group are switched **once
per distinct material**, not once per entity (the per-draw work — matrix offset, vertex/index
buffers, the indirect draw itself — is unchanged). The grouping is internal: it does not change
the rendered image and there is nothing to configure.
> **Constraint:** the batching reorders the draws, which is safe here because every pipeline in
> the engine is **opaque** (`BlendState::REPLACE`, no alpha blending) — the depth buffer resolves
> the draw order. If transparent materials are ever added, the transparent draws must be isolated
> (sorted back-to-front at the end of the pass) and must not interleave with the grouped opaque
> draws.
## Frustum culling (opt-in)
Culling is **off by default**. The culling pass still runs, but with culling disabled it marks
@@ -108,7 +122,7 @@ objects were off-screen anyway — so the proof is in the counts, not the image)
This readback is the reference truth when a shader bug is suspected: it shows both the computed
counts and the raw inputs of the cull pass, independently of what ends up on screen. (It is how
the 2026-09-22 « black window » bug — an inverted WGSL `select` argument order — was diagnosed
and verified fixed, see the DRAFT D14 in `docs/DRAFT.md`.)
and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
## Limitations