28 KiB
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 bindinguniformunique est plafonnée àmax_uniform_buffer_binding_size(64 ko), et (2) un offset de bufferuniformdoit être un multiple demin_uniform_buffer_offset_alignment(256 o). Une matrice de 64 o ne peut donc jamais être adressée individuellement par un offset dynamiqueuniform: 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 deselecten WGSL (select(reject, accept, cond)renvoie le second argument quandcondest vrai — l'inverse de la convention HLSL). Corrigé et vérifié par readback (cf. D14).
Objectifs
- 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. - 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 — Draw indirect : le rendu de la scène passe en
draw_indexed_indirect/draw_indirectpar 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.rschange (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_scenemais produit un rendu identique au chemin CPU actuel. standard_shader.wgslnon modifié (D2) : le binding group 1 restevar<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_indirectpar 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_entityrenvoieErrau-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 dynamiqueslot × 256qui 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 parrender_scene; ils restent pour le chemin bas niveaurender()(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 pourdraw_indirectetdraw_indexed_indirect). - Le buffer est zéro-initialisé à la création ; le culling écrit
a(etb) à chaque frame ;c..erestent 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 parqueue.write_bufferchaque frame (les transforms viennent deScene, modifiés par l'utilisateur dansupdate()).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 slotuniform« object » (64 ko = la limitemax_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
Geometrycalcule son AABB (min/max des positions) à la construction ;Meshle conserve (champbbox: BBox).Scene::create_mesh/add_meshen dérive unBBoxSlot(32 o :min: vec3f+max: vec3f, coins locaux du box) stocké dansBBoxBuffer(32 o × 256 = 8 ko, storage, upload une fois par mesh — ré-upload seulement quand l'ensemble des meshes change, viagpu_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 esttranslation + rotation * center_local(pas d'échelle sur le centre — l'échelle est portée par le rayon). Visible si aucun plan n'adistance(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.xdu slot de transform (rempli par le Renderer chaque frame) ; le culling litbboxes[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·Spouri in 0..num_slots(T=translation, R=quaternion normalisé, S=scale →mat4x4). La construction WGSL reproduitTransform::to_matrix()(colonnes de rotation échelonnées par S, translation dans la 4e colonne).cull(@workgroup_size(64)) : pouri in 0..num_slots: si inactive →draws[i] = 0; sinon test sphère vs 6 plans →draws[i].a = visible ? args : 0.- Deux
ComputePipelinecréé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) dansCullUniforms→ le compute parcourt tous les slots alloués (les tombstones sont marqués inactifs et produisentdraws[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 querender_scene).AppBuilder::with_culling(self, bool) -> Self; appliqué dansresumed()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é. Ledemoactive 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::entitiespasse deHashMap<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é)
Entityest inchangé (pas de champ label ; le label reste dansentity_labels).- L'ordre d'itération est stable (ordre d'insertion) — important pour la cohérence des
slots (le
HashMapactuel a un ordre d'itération non-déterministe). add_entity/add_entity_with_transform: trouvent un slot libre (premierNoneou append silen < MAX_GPU_ENTITIES) ;Errsi 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_scenepasse 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_scenepasse de&selfà&mut self(nécessaire pourqueue.write_bufferdes buffers transform/cull + mise à jour du cache de bind groups).App::render_scene(qui prend déjà&mut self) utiliserenderer_mut()→ aucun exemple n'appelleRenderer::render_scenedirectement (le defaultAppHandler::renderpasse parApp::render_scene) → aucune rupture d'API pour les exemples.- Le chemin bas niveau
Renderer::render()(utilisé parmanual.rs) garde les UBO d'objet CPU →manual.rsn'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 dansconf.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 :- une binding
uniformunique est plafonnée àmax_uniform_buffer_binding_size(64 ko) ; - un offset de buffer
uniform(dynamique ou statique) doit être un multiple demin_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 seraitslot × 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 esti × 256(toujours aligné), et 256 slots × 256 o = 64 ko est le maximum adressable par une bindinguniformunique. (Les buffers transforms / bboxes / indirect sont des bindingsstorage— limite 128 Mo, pas de règle d'offset 256 o — donc ils gardent leurs tailles naturelles 64 / 32 / 80 o.) - une binding
-
Bind group « object » sur une slice de 64 o : le bind group
matrix_object_bglie 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
uniformpar 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, avecdrawsau 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 queculling = 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
selecten WGSL estselect(reject, accept, cond)— renvoie le second argument quandcondest vrai, le premier quand il est faux : l'inverse de la convention HLSL (select(trueVal, falseVal, cond)) sur laquelle le passcullavait é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 constanteselect(2.0, 3.0, true)→ 3.0 sur le GPU (NVIDIA, Vulkan) ; (3) source de naga 30 (le compilateur WGSL de wgpu) : le frontend mappearg0 → reject,arg1 → accept, et les backends SPIR-V/GLSL émettentcond ? 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 degpu_driven.wgsl+ piège documenté dansAGENTS.md. - Vérification : readback après correction — les 7 entités du
demoreprennent leurs comptes pleins (6/36/3840/960/384/192/2304, égaux auxflags.zpackés côté CPU) ; une entité ajoutée hors frustum (z = 60, au-delà du plan lointain) est cullée à 0 ; les slots ≥num_slotsrestent à 0.cargo build --workspacesans avertissement,cargo test --workspacevert (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-inWSG_DEBUG_DUMP=1dans ledemo), 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_bufferpar 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
cargo build --workspace— OK.cargo test --workspace— 45 tests unitaires + nouveaux tests frustum/geometry + 2→3 tests WGSL + 3 doctests, tous verts.cargo fmt --all -- --check— clean.demoen 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.- Tous les autres exemples (cube, simple, shadow_test, spot_test) en headless → exit 0, rendu identique (culling OFF par défaut, chemin indirect actif).
manual.rsen headless → exit 0 (cheminrender()bas niveau inchangé, UBO CPU).- Check liens : 0 lien cassé (nouveaux
docs/user/gpu-driven.md+ liens README). - Aucun caractère accentué dans les fichiers modifiés (sauf
docs/tech/, DRAFT, PLAN, ROADMAP, DOCUMENTATION) — les commentaires WGSL traduits. - WGSL valide : le nouveau
gpu_driven.wgslcompile (testwgsl_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 dansrenderer.rs.
compute_matrices+culldansgpu_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_scene100 % indirect (main + shadow). Écart :render_scenereste&self(buffers persistants créés dansnew()+Cell<bool>pour le culling) — plus simple que le&mut selfprévu, et sans ré-allocation par frame.Sceneslots 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 queiter_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).demoactive le culling (with_culling(true)) ;cargo build --workspacevert.- 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.