Files
wsg/docs/DRAFT.md
T
Jérôme Bousquié 3a424afe8c GPU culling
2026-09-22 15:48:15 +02:00

28 KiB
Raw Blame History

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<uniform> 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<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).

D3 — Layout des slots d'indirect draw : 80 o (pdc(16,20))

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

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

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.

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.

  • 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.
  • 4 buffers GPU (transform, matrices, bboxes, draw-args) + 1 buffer cull-uniforms dans le Renderer.
  • 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.
  • 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).
  • Geometry::bbox() + BBoxSlot ; upload du buffer de bboxes à chaque frame (peu coûteux, toujours correct si des meshes sont ajoutés).
  • math/frustum.rs (Gribb-Hartmann [0,1]) + 5 tests.
  • AppBuilder::with_culling(bool) + Renderer::set_culling (OFF par défaut).
  • demo active le culling (with_culling(true)) ; cargo build --workspace vert.
  • Commentaires WGSL EN ; aucun accent dans les fichiers de code touchés.
  • 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.