Root cause of the user-reported artifacts (stripes disappearing on the far LOD): normals were RECOMPUTED from the surviving faces after the collapse. uv_sphere is wound inward, so the recomputed normals pointed inward — the far LOD was back-face-lit (measured deviation 2.0 vs 0.0 on L0). Fix — normals are INHERITED, never recomputed: - welded() now also welds normals (first-encountered per cluster) - Collapse owns the normal table; collapse_edge λ-blends + renormalizes at the same λ as the position (no seam guard: the attribute-aware weld kept hard-edge vertices separate, so no edge crosses a shading break) - compaction reads the post-collapse table instead of recomputing - the outward reorientation added earlier is removed: the source winding + normals are preserved as-is, so every LOD level is shading-compatible with L0 whatever the source orientation Rim protection (attribute-aware weld leaves UV-seam slits / pole fans as boundary rims): a face touching such a rim is never removed while interior collapses remain — strict PQ mode (edges whose incident faces are fully interior, re-validated at pop) with a best-effort fallback when the interior alone cannot reach the target. Seam-free meshes (icosahedron) stay topologically closed; sewn meshes stay geometrically complete (no hole at the slit) — tests now assert seam-column survival. Docs: gpu-driven.md §LOD, ARCHI_CPU_GPU LOD note, ROADMAP 4.3 updated with the attribute-aware weld + rim protection + inherited normals. Gate: fmt ✓, check 0 warnings ✓, 104 tests ✓, demo runs ✓. Measured (uv_sphere 32×20): normal max deviation 0.0000 on L0–L2 (was 2.0000), UV max error 0.0084 vs analytical (was 0.5000).
11 KiB
type, title, description, tags, actor, sources, generated, verified, status, stale_after
| type | title | description | tags | actor | sources | generated | verified | status | stale_after | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Technical Specification | GPU-Driven 3D Rendering Architecture with wGPU | Technical specification for GPU-driven 3D rendering architecture using wgpu, focusing on CPU-GPU workload distribution and performance optimization |
|
person/jerome |
|
true | current | 2027-01-31 |
Architecture de Rendu 3D GPU-Driven avec wGPU : 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, Étapes 17–19, 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 pointscompute_matrices+cull, un module, layout explicite à 3 groupes) et les buffers de slots duRenderer(TransformSlot/MatSlot/BBoxSlot/DrawSlot/CullUniforms, capacité fixe de 256 slots). 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é dedocs/DRAFT.mdaprè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 (plafonduniformWebGPU) ; (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 deselecten 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 degpu_driven.wgslet dansAGENTS.md. Batching par material (Étape 18, 2026-09-22) : la passe principale émet désormais les draws groupés parMaterial(1set_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 ». LOD (Étape 19, 2026-09-23) : le passcullremplit désormais les arguments indirects à partir du niveau de détail du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait côté CPU (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique —math/lod.rs, pur et unit-testé) ; le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont générés par quadric edge collapse (Garland–Heckbert) au setup (Geometry::decimated: les arêtes au coût quadrique minimal sont repliées en premier ; soudure consciente des attributs — un doublon ne fusionne que si UV proches (≤ ½ tuile) ET normales proches (dot > 0.9) — ; un mesh sans couture reste fermé, sur un mesh couturé les lèvres de couture sont protégées (pas de trous, pas de « books ») ; UVs/couleurs/normales interpolés au repli, normales héritées de la source (jamais recalculées — l'éclairage reste identique au niveau 0 quelle que soit l'orientation source), jamais à travers une seam UV ; rebase u16) et empilés dans les buffers vertex/index du mesh (offsets en unités d'élément, pas d'octet — c'est ce qu'exigent les argumentsdrawIndirect*de WebGPU ; plafond u16 : 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ;set_lod_enabled(false)restaure un rendu bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail :docs/user/gpu-driven.md§ « Level of Detail ».
- Répartition des Rôles : CPU vs GPU (La Source de Vérité)
Pour éviter les goulets d'étranglement dus aux allers-retours sur le bus PCIe, la règle d'or est la suivante : Le CPU est le cerveau logique, le GPU est l'exécutant visuel.
Côté CPU (Source de Vérité)
- Ce qu'il conserve : Les données logiques et les transformations brutes des objets (ex: Vec contenant la position, la rotation, et l'échelle).
- Ce qu'il fait : Il gère la logique de jeu, l'IA, le réseau et les interactions globales.
- Ce qu'il ne fait plus : Il ne calcule plus les matrices de transformation mondiales (World Matrices) en masse, et ne fait plus de tests de visibilité unitaires (le culling frustum reste 100 % GPU).
- Ce qu'il fait en plus (LOD, Étape 19) : le choix du niveau de détail par entité — O(N) projections de sphères en pixels + hystérésis, coût négligeable. C'est l'unique décision de visibilité/détail conservée côté CPU : elle dépend de la taille écran (un choix artistique), pas de la géométrie, et l'hystérésis a besoin de l'état de la frame précédente.
Côté GPU (Exécutant Autonome)
- Ce qu'il calcule : Les World Matrices, le Frustum Culling, et la génération des listes de dessin indirectes.
- Ce qu'il conserve : Les buffers de données persistants en VRAM (Storage Buffers) qui vivent d'une frame à l'autre sans jamais redescendre vers le CPU.
- Le Pipeline d'Exécution par Frame (Ordre des Passes)
L'exécution des tâches s'appuie sur une structure séquentielle stricte au sein d'un même CommandEncoder. Le driver et wGPU s'occupent des barrières de mémoire implicites entre chaque étape.
[ CPU : Envoi des Transforms bruts ]
↓
[ Pass 1 : Compute (Calcul World Matrices + Frustum Culling + Indirect Draw Buffer) ]
↓ (Barrière de mémoire automatique gérée par le driver)
[ Pass 2 : Render (Draw Indexed Indirect basé sur les objets visibles) ]
Étape par étape :
- Mise à jour CPU (Minimaliste) : Le CPU écrit les transformations brutes (Transform) modifiées dans un buffer GPU mappé (single buffer en phase initiale — la synchronisation est assurée par
queue.submit()qui garantit la séquence d'exécution), et le niveau LOD de chaque slot (1 u32/slot, Étape 19). Double buffering sera ajouté uniquement si des artefacts apparaissent à haute fréquence (> 90 fps). - Pass de Calcul (Compute Pass) :
- Calcul des World Matrices : Un compute shader lit les transformations brutes et génère la matrice 4x4 finale pour chaque mesh.
- Frustum Culling GPU : Un compute pass dédié (
cull) compare la sphère bounding de chaque objet (D5 — conservative, dérivée de l'AABB locale du mesh et de l'échelle de l'entité) avec les 6 plans du frustum de la caméra. - Remplissage du Buffer Indirect : le pass
culllit le niveau LOD du slot, en choisit la ligne dans la table LOD du mesh (LodTable: 4 lignes d'offsets/comptes en unités d'élément) et écrit les arguments dans leDrawSlot(80 o) — mis à 0 si l'objet est cullé ou inactif (no-op). Le niveau 0 porte les comptes du mesh complet, donc LOD désactivé ≡ pré-LOD bit-à-bit.
- Pass de Rendu (Render Pass) :
- Le CPU émet un draw indirect par slot (écart D1 — la spécification initiale prévoyait une commande unique fusionnée) ; les slots à compte 0 (cullés/inactifs/vides) sont des no-ops.
- Le GPU pioche directement dans le buffer préparé par le compute pass et dessine uniquement les objets visibles, sans intervention du CPU.
- Stratégie de Synchronisation
- Sécurité de l'ordre : L'ordre d'appel des méthodes sur le CommandEncoder (begin_compute_pass suivi de begin_render_pass) garantit l'ordre d'exécution séquentiel sur le GPU.
- Barrières de mémoire : Le pilote insère automatiquement les barrières nécessaires pour s'assurer que le buffer de la WorldMatrix et le buffer Indirect sont complètement écrits par le compute shader avant d'être lus par le render pipeline.
- Éviter le Readback (map_async) : Sauf cas exceptionnel (débug ou interaction scriptée critique), aucune donnée géométrique ou de position ne doit remonter du GPU vers le CPU. Le CPU fait confiance à sa propre structure de données initiale pour la logique métier.
- Synthèse des Structures de Données en VRAM
L'implémentation utilise les buffers wGPU suivants (tous créés par le Renderer à l'initialisation, capacité fixe de 256 slots) :
| Buffer | Rôle | Type wGPU | Direction du flux |
|---|---|---|---|
| Transform Buffer | Positions/rotations/échelles brutes + flags par entité (64 o/slot) | Storage Buffer | CPU → GPU (chaque frame, write_buffer) |
| Matrix Buffer | World Matrices finales calculées (256 o/slot, padded — D12) | Storage + Uniform Buffer | GPU (Calculé) → GPU (Lu par le Render) |
| Bounding Box Buffer | Coins min/max de l'AABB de chaque mesh (32 o/slot) | Storage Buffer | CPU → GPU (quand l'ensemble des meshes change) |
| Indirect Draw Buffer | Comptes de draw par slot (80 o/slot, zéro = no-op) | Indirect + Storage Buffer | GPU (Rempli par Compute) → GPU (Lu par le Render) |
| CullUniforms | 6 plans du frustum + num_slots + culling (112 o) |
Uniform Buffer | CPU → GPU (chaque frame) — réservé au compute (group 2) |
| Lod Levels | Niveau LOD par slot choisi par le CPU (4 o/slot) | Storage Buffer (read-only) | CPU → GPU (chaque frame) |
| Lod Tables | Table par mesh : count + 4 lignes de 16 o (offset/compte d'éléments) (80 o/mesh) |
Storage Buffer (read-only) | CPU → GPU (quand l'ensemble des meshes change) |
Buffers de géométrie LOD (Étape 19) : les niveaux d'un mesh sont empilés — un seul buffer vertex et un
seul buffer index par mesh, contenant les niveaux concaténés (L0, L1, …). Les lignes de la table LOD portent
les offsets en unités d'élément (premier vertex / premier index), car les arguments drawIndirect* de
WebGPU s'expriment en éléments, et le buffer est lié en entier à l'offset 0. Conséquences : un mesh LOD ne
peux dépasser 65 535 sommets au total (indices u16) et 4 niveaux (MAX_LOD_LEVELS) ; le mélange indexé/non-indexé
dans un même mesh est supporté (la commande de draw par slot suit le niveau choisi par le CPU).
Liens
- ARCHI_APP · ARCHI_RENDU · ARCHI_ARENES · FRAME_LOOP
- Documentation utilisateur : docs/user · README racine · ROADMAP
- Référence API :
cargo doc -p wsg-lib --no-deps