choix archi particles

This commit is contained in:
Jérôme Bousquié
2026-09-25 22:07:48 +02:00
parent d4c2d93fc5
commit fecfdcd2d2
+65 -13
View File
@@ -52,11 +52,11 @@
--- ---
## 2. État par particule (64 bytes) ## 2. État par particule (80 bytes avec `uv_rect` [D15])
```rust ```rust
/// Miroir du struct WGSL `Particle`. /// Miroir du struct WGSL `Particle`.
/// 64 bytes, `#[repr(C)]`, `Pod + Zeroable`. /// 80 bytes avec `uv_rect` [D15], `#[repr(C)]`, `Pod + Zeroable`.
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)] #[derive(Copy, Clone, Pod, Zeroable)]
pub struct Particle { pub struct Particle {
@@ -71,7 +71,8 @@ pub struct Particle {
pub angle: f32, // offset 48 — rotation 2D courante (radians) pub angle: f32, // offset 48 — rotation 2D courante (radians)
pub angular_vel: f32, // offset 52 — vitesse angulaire (rad/s) pub angular_vel: f32, // offset 52 — vitesse angulaire (rad/s)
pub color: [f32; 4], // offset 56 — RGBA (alpha modulée par le driver) 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) | | 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) | | Rotation 2D | `angle` + `angular_vel` | `angle += angular_vel * dt` | Spin (turbulence, pétale) |
| Opacité | `color.a` | `color.a = life / max_life` | Fade out progressif | | 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. > 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)`). > **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 | | 65 536 | 4 MB | 245 MB/s | Max recommandé pour CPU |
| 100 000 | 6.4 MB | 380 MB/s | → Préférer driver GPU | | 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) ## 3. Le Pool (ressource GPU)
@@ -107,7 +111,8 @@ Chaque particule porte un **transform complet** :
### 3.1 Création ### 3.1 Création
Le pool est créé **une fois** au setup. Il alloue : 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 pipeline render (billboard instancé)
- Le bind group layout - Le bind group layout
- La texture + sampler (si fournie) - La texture + sampler (si fournie)
@@ -519,6 +524,7 @@ struct Particle {
angle: f32, angle: f32,
angular_vel: f32, angular_vel: f32,
color: vec4<f32>, color: vec4<f32>,
uv_rect: vec4<f32>, // [D15]
} }
struct EmitterParams { 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 > 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. > 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, size: f32, size_growth: f32,
angle: f32, angular_vel: f32, angle: f32, angular_vel: f32,
color: vec4<f32>, color: vec4<f32>,
uv_rect: vec4<f32>, // [D15]
} }
struct CameraParams { struct CameraParams {
@@ -617,6 +626,8 @@ fn vs_main(
) -> VsOut { ) -> VsOut {
var out: 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 // Early-out si au-delà du count alive
if f32(ii) >= count_buf { if f32(ii) >= count_buf {
out.clip = vec4(0.0, 0.0, -1.0, 1.0); // hors écran out.clip = vec4(0.0, 0.0, -1.0, 1.0); // hors écran
@@ -710,7 +721,9 @@ App::render_scene(frame) :
└─ 5. Present └─ 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`). > instances). Le vertex shader fait un early-out (`if ii >= count → clip hors écran`).
> C'est moins élégant qu'un indirect draw mais : > C'est moins élégant qu'un indirect draw mais :
> - Évite un buffer indirect + un compute de réduction > - É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) | | 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↔objet | O(n×m) | Driver CPU + custom_force |
| Pas de collision particule↔particule | O(n²) | Spatial hash (V3) | | 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) | | 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) | | 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 | | # | Question | Tendance | Impact |
|---|----------|----------|--------| |---|----------|----------|--------|
| 1 | `draw(4, max_count)` vs indirect draw | v1 : early-out en shader. v2 : indirect. | Complexité du pipeline | | 1 | `draw(4, max_count)` vs indirect draw | **Tranché → D17** (compaction + indirect en v1, §18) | — |
| 2 | alive_count : CPU count vs atomic GPU | v1 : CPU (driver compte). Simple. | Précision du count | | 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<RenderPipeline> partagé) | Mémoire | | 3 | Multi-pools même texture : partager le pipeline ? | Oui (Arc<RenderPipeline> partagé) | Mémoire |
| 4 | Le pool est-il `Send` ? (multi-thread spawn) | Oui — le buffer est GPU, le driver est `Send` | Flexibilité | | 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 | | 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 | | D1 | **Pool ≠ Driver** (séparation stricte) | Flexibilité, swappability, zéro waste |
| D2 | **3 drivers** : GPU, CPU, Manual | Gradient de contrôle | | D2 | **3 drivers** : GPU, CPU, Manual | Gradient de contrôle |
| D3 | **1 driver par pool** (v1) | Simplicité. Multi-drivers = V2. | | 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. | | 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. | | 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** (v1) | Pas de buffer indirect. GPU skip les mortes. | | 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. | | 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. | | 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. | | 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. | | 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. | | D13 | **Preset methods** (`GpuEmitterConfig::fire()`) | UX : 1 ligne pour un effet correct. |
| D14 | **`custom_force` (driver CPU)** | Le seul cas où CPU > GPU : interactions. | | 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<f32>` (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).