From fecfdcd2d27ce6678c20c4b22745f0acf654dde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Bousqui=C3=A9?= Date: Fri, 25 Sep 2026 22:07:48 +0200 Subject: [PATCH] choix archi particles --- docs/tech/ARCHI_PARTICULES.md | 78 +++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/docs/tech/ARCHI_PARTICULES.md b/docs/tech/ARCHI_PARTICULES.md index 16b036a..087d6f9 100644 --- a/docs/tech/ARCHI_PARTICULES.md +++ b/docs/tech/ARCHI_PARTICULES.md @@ -52,11 +52,11 @@ --- -## 2. État par particule (64 bytes) +## 2. État par particule (80 bytes avec `uv_rect` [D15]) ```rust /// Miroir du struct WGSL `Particle`. -/// 64 bytes, `#[repr(C)]`, `Pod + Zeroable`. +/// 80 bytes avec `uv_rect` [D15], `#[repr(C)]`, `Pod + Zeroable`. #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] pub struct Particle { @@ -71,7 +71,8 @@ pub struct Particle { pub angle: f32, // offset 48 — rotation 2D courante (radians) pub angular_vel: f32, // offset 52 — vitesse angulaire (rad/s) pub color: [f32; 4], // offset 56 — RGBA (alpha modulée par le driver) - // Total : 64 bytes + pub uv_rect: [f32; 4], // offset 64 — zone UV (ox, oy, sx, sy) [D15] + // Total : 80 bytes } ``` @@ -86,6 +87,7 @@ Chaque particule porte un **transform complet** : | Scale 2D | `size` + `size_growth` | `size += size_growth * dt` | Croissance (fumée) / rétrécissement (feu) | | Rotation 2D | `angle` + `angular_vel` | `angle += angular_vel * dt` | Spin (turbulence, pétale) | | Opacité | `color.a` | `color.a = life / max_life` | Fade out progressif | +| Zone UV | `uv_rect` | fixe (set au spawn) [D15] | Multi-motifs depuis une texture (atlas) | > Le RGB de `color` est fixé au spawn (CPU). Seule l'alpha est modulée par le driver. > **V2** : color ramp (`mix(color_start, color_end, 1.0 - life/max_life)`). @@ -100,6 +102,8 @@ Chaque particule porte un **transform complet** : | 65 536 | 4 MB | 245 MB/s | Max recommandé pour CPU | | 100 000 | 6.4 MB | 380 MB/s | → Préférer driver GPU | +> [D15] Avec `uv_rect` (80 B) : tous les chiffres × 1.25 (10K → 800 KB ≈ 48 MB/s, 100K → 8 MB ≈ 480 MB/s). Verdicts inchangés. + --- ## 3. Le Pool (ressource GPU) @@ -107,7 +111,8 @@ Chaque particule porte un **transform complet** : ### 3.1 Création Le pool est créé **une fois** au setup. Il alloue : -- Le buffer storage (N × 64 bytes) +- Le buffer storage (N × 80 bytes [D15]) +- Le buffer d'index compact + args indirect ([D17], cf §18) - Le pipeline render (billboard instancé) - Le bind group layout - La texture + sampler (si fournie) @@ -519,6 +524,7 @@ struct Particle { angle: f32, angular_vel: f32, color: vec4, + uv_rect: vec4, // [D15] } struct EmitterParams { @@ -570,7 +576,9 @@ fn cs_update() { } ``` -> **alive_count** : pour la v1, le count est géré par le CPU (le driver compte les +> ⚠️ **[D17] Supplanté** : le count est exact, produit par la compaction fused dans ce compute (cf §18). +> +> **alive_count** (version initiale) : pour la v1, le count est géré par le CPU (le driver compte les > spawns/kills). Le compute ne fait que l'intégration. Un atomic dans le compute > serait plus "pur GPU" mais ajoute de la complexité sans gain visible en v1. @@ -586,6 +594,7 @@ struct Particle { size: f32, size_growth: f32, angle: f32, angular_vel: f32, color: vec4, + uv_rect: vec4, // [D15] } struct CameraParams { @@ -617,6 +626,8 @@ fn vs_main( ) -> VsOut { var out: VsOut; + // ⚠️ [D17] version initiale : early-out + draw(4, max_count). + // → remplacé par index buffer compact + drawIndirect, cf §18. // Early-out si au-delà du count alive if f32(ii) >= count_buf { out.clip = vec4(0.0, 0.0, -1.0, 1.0); // hors écran @@ -710,7 +721,9 @@ App::render_scene(frame) : └─ 5. Present ``` -> **Note sur `draw(4, max_count)`** : on drawe TOUS les slots du pool (max_count +> ⚠️ **[D17] NOTE SUPPLANTÉE** : voir §18 — compaction + `drawIndirect`, plus de early-out. +> +> **Note sur `draw(4, max_count)`** (version initiale) : on drawe TOUS les slots du pool (max_count > instances). Le vertex shader fait un early-out (`if ii >= count → clip hors écran`). > C'est moins élégant qu'un indirect draw mais : > - Évite un buffer indirect + un compute de réduction @@ -900,9 +913,9 @@ Utilisée par défaut si l'utilisateur ne fournit pas de texture. | Fade alpha linéaire | Simple, couvre 90% | Color ramp (mix start→end) | | Pas de collision particule↔objet | O(n×m) | Driver CPU + custom_force | | Pas de collision particule↔particule | O(n²) | Spatial hash (V3) | -| `draw(4, max_count)` + early-out | Simple, pas de buffer indirect | Indirect draw (V2) | +| ~~`draw(4, max_count)` + early-out~~ → **[D17] compaction + indirect en v1** (§18) | Supplanté | — | | Spawn CPU uniquement | Le random au spawn est CPU | GPU spawn (curated random) | -| Pas de texture sheet (UV animation) | 1 UV par particule | UV offset + animation | +| Pas de texture sheet **animée** ([D15] donne déjà la zone UV statique par particule en v1) | Zone UV statique par vie | UV animation f(t) (V2) | | Pas de sorting (alpha) | Additive = commutatif. Alpha = approximation | Back-to-front sort (V2) | --- @@ -911,8 +924,8 @@ Utilisée par défaut si l'utilisateur ne fournit pas de texture. | # | Question | Tendance | Impact | |---|----------|----------|--------| -| 1 | `draw(4, max_count)` vs indirect draw | v1 : early-out en shader. v2 : indirect. | Complexité du pipeline | -| 2 | alive_count : CPU count vs atomic GPU | v1 : CPU (driver compte). Simple. | Précision du count | +| 1 | `draw(4, max_count)` vs indirect draw | **Tranché → D17** (compaction + indirect en v1, §18) | — | +| 2 | alive_count : CPU count vs atomic GPU | **Tranché → D17** (count exact GPU via compaction, §18) | — | | 3 | Multi-pools même texture : partager le pipeline ? | Oui (Arc partagé) | Mémoire | | 4 | Le pool est-il `Send` ? (multi-thread spawn) | Oui — le buffer est GPU, le driver est `Send` | Flexibilité | | 5 | `emitter_burst` : overwrite les plus vieilles si pool saturé ? | Oui (ring buffer sémantique) | Robustesse | @@ -946,10 +959,10 @@ Chaque étape est un DRAFT séparé, testable indépendamment. | D1 | **Pool ≠ Driver** (séparation stricte) | Flexibilité, swappability, zéro waste | | D2 | **3 drivers** : GPU, CPU, Manual | Gradient de contrôle | | D3 | **1 driver par pool** (v1) | Simplicité. Multi-drivers = V2. | -| D4 | **64 bytes/particule** | Couvre pos/vel/life/size/angle/color. Align 16. | +| D4 | **80 bytes/particule** (avec `uv_rect`, [D15]) | Couvre pos/vel/life/size/angle/color/uv. Align 16. | | D5 | **Billboard camera-facing** (pas world-facing) | Standard pour les VFX. Plus simple. | -| D6 | **Quad via vertex_index** (pas de vertex buffer) | Cohérent avec TM/bloom. 0 buffer vertices. | -| D7 | **`draw(4, max_count)` + early-out** (v1) | Pas de buffer indirect. GPU skip les mortes. | +| D6 | **Quad via vertex_index** (⚠️ [D17] ajoute un vertex buffer d'index 1 u32/vertex ; le quad reste généré en shader) | Cohérent avec TM/bloom. | +| D7 | ~~**`draw(4, max_count)` + early-out**~~ → **supplanté par D17** (§18) | — | | D8 | **Spawn toujours CPU** (même driver GPU) | Le random + la décision "qui spawn" est CPU. Le GPU intègre. | | D9 | **Blend figé au pipeline** (1 mode par pool) | Pas de switch de pipeline par frame. | | D10 | **Depth test oui, depth write non** | Transparence correcte sans artefacts. | @@ -957,3 +970,42 @@ Chaque étape est un DRAFT séparé, testable indépendamment. | D12 | **Pool inactif si pas de driver** | Zéro coût. L'allocation buffer est le seul coût. | | D13 | **Preset methods** (`GpuEmitterConfig::fire()`) | UX : 1 ligne pour un effet correct. | | D14 | **`custom_force` (driver CPU)** | Le seul cas où CPU > GPU : interactions. | +| D15 | **UV par particule** (`uv_rect` vec4 → struct 80 B) | WebGPU : pas d'indexage dynamique de textures → multi-motifs via UV. §18 | +| D16 | **Layout complet figé, pas d'opt-in par attribut** | Gain nul, explosion de variants, coupling pool/émetteur. §18 | +| D17 | **Compaction + indirect draw** (fused dans le compute) | Scale avec count actif, pas capacity. Count exact GPU. §18 | + +--- + +## 18. Décisions de la session de design (à appliquer par DRAFT) + +Ces 3 décisions tranchent les questions §15 (Q1, Q2) et supplantent D4/D6/D7. +Le reste du doc décrit la version initiale : appliquer les impacts listés ici. + +### D15 — UV par particule (`uv_rect`) + +- `uv_rect: vec4` (ox, oy, sx, sy en UV texture) ajouté à `Particle` → struct 64 → **80 B** (déjà appliqué dans §2, §8.1, §8.2). +- VS : `out.uv = uv_rect.xy + (q + 0.5) * uv_rect.zw` (au lieu de `q + 0.5`). Défaut (0,0,1,1) = comportement d'avant. +- **Pourquoi** : WebGPU interdit l'indexage dynamique de textures en uniform (1 texture = 1 bind group = 1 pipeline). La variété de motifs ne peut venir QUE des UV → multi-motifs depuis UNE texture (atlas partagé entre presets). +- Statique par vie (fixé au spawn). Animation d'atlas (uv = f(t)) = **v2** (cf §14). +- Impact restant : §5.4 (`uv_rect` dans `GpuEmitterConfig`, défaut (0,0,1,1)), §10.2 (exemple manual : `uv_rect` à poser), §3.2/§6 (tailles × 1.25). + +### D16 — Layout COMPLET et figé, PAS d'opt-in par attribut + +- Le pool a TOUJOURS le layout complet. Pas de variante « pool léger » (sans color/angle/vel). +- **Pourquoi** : + a) gain nul en pratique : la VRAM est allouée UNE fois (capacity = le réglage utilisateur), pas par frame ; + b) opt-in = explosion de variants : 4 attributs optionnels → jusqu'à 16 combos pipeline/shader (attribut absent = layout WGSL différent = shader variant différent) ; + c) piège de coupling : le pool est créé UNE FOIS et partagé — impossible de l'élargir après coup pour un émetteur voulant l'attribut manquant. +- Le « enable » utile est au niveau **config d'émetteur** (quels champs le driver écrit/randomise au spawn) — coût GPU zéro. +- Impact : aucun (le §2 reste le seul layout). Ferme toute question « pool minimal ». + +### D17 — Compaction + indirect draw (supplante D7, tranche Q1+Q2) + +- Emulation d'instancing standard WebGPU : **index buffer** (`compact_index`, 1 u32 par vertex = 4 copies du slot par instance, vertex buffer layout 1 × u32 stride 4) + storage load en VS (`particles[idx]` au lieu de `particles[ii]`). +- La **compaction est FUSIONNÉE dans le compute d'intégration** (~15 lignes WGSL, pas de 3e pass) : chaque slot alive (life > 0 après intégration) fait `atomicAdd(&compact_count)` et écrit son slot 4× dans `compact_index` ; le dernier workgroup (détection par atomic global vs nb de workgroups) écrit les args indirect. +- Draw : **`drawIndirect`** (vertexCount = 4 × alive, instanceCount = alive) au lieu de `draw(4, max_count)` + early-out. Le VS n'a plus besoin du count → `count_buffer` remplacé par `indirect_args` (20 B : 5 × u32). +- **Pourquoi** : la compaction scale avec le count **actif** ; le template statique (early-out) taxe la **capacity** à CHAQUE frame — scène idle avec grand pool = 0.3–1 ms/frame de VS inutile. Coût compaction : pire cas ~0.3 ms sur iGPU faible (scène active). +- Conséquence : le count est **exact et GPU** (sortie de la compaction) → tranche Q2 (plus de count CPU estimé). +- Par driver : **GPU** = compaction fused (ci-dessus). **CPU** = sait exactement les slots alive → build lui-même l'index (4 copies) + args au CPU. **Manual** = `set_count` remplit l'index identité (l'utilisateur pack ses alive en tête de buffer) + args. +- Non-régression : pool sans driver → args indirect = 0 → `drawIndirect` no-op (§12 inchangé). +- Impact restant : §3.2/§6 (ajouter `compact_index` N × 4 × u32 + `indirect_args` 20 B), §7.2 (vertex buffer layout), §8.1 (compaction fused), §8.2 (supprimer early-out + `count_buf`, lire l'index), §9 (d = `drawIndirect`), §4.2/§4.3/§4.4 (chacun produit index + args).