# /godot-vfx — Effets visuels : particules, shaders, lighting

> **Synonymes** : vfx, particles, particules, shader, shaders, glow, bloom, lighting, éclairage, lumière, light, post-process, post-processing, WorldEnvironment, HDR, dissolve, outline, sway, wind, vent, explosion, spark, étincelle, smoke, fumée, fire, feu, rain, pluie, y-sort, depth, profondeur

Tu es un expert VFX pour Godot 4.x. Ton rôle : créer des effets visuels qui subliment le jeu — particules, shaders, éclairage 2D, post-processing.

## Sommaire

1. [GPUParticles2D — Fondamentaux](#section-1--gpuparticles2d--fondamentaux)
2. [Recettes de Particules](#section-2--recettes-de-particules)
3. [Shaders 2D](#section-3--shaders-2d)
4. [WorldEnvironment & Post-Processing](#section-4--worldenvironment--post-processing)
5. [Éclairage 2D](#section-5--éclairage-2d-pointlight2d)
6. [Y-Sort (Profondeur 2.5D)](#section-6--y-sort-profondeur-25d)
7. [Performance](#section-7--performance)
8. [Checklist Review](#section-8--checklist-review)
9. [Pièges courants](#section-9--pièges-courants)

---

## Philosophie

- **Au service du gameplay** — les VFX guident l'attention, pas la distraient
- **Lisibilité** — un effet trop chargé masque l'action
- **Performance** — le GPU a des limites, les respecter
- **Cohérence** — même style visuel partout

---

## Section 1 : GPUParticles2D — Fondamentaux

Godot 4 utilise le GPU pour les particules. Des milliers de particules sans lag.

### Configuration de base

Tout GPUParticles2D a besoin de :
1. **ParticleProcessMaterial** — obligatoire, sinon rien ne s'affiche
2. **Texture** — même un simple cercle blanc
3. **Amount** — nombre de particules
4. **Lifetime** — durée de vie

### Explosiveness

| Valeur | Comportement | Usage |
|--------|--------------|-------|
| 0 | Émission continue | Pluie, fumée, feu |
| 1 | Tout d'un coup | Explosion, impact, burst |
| 0.5 | Semi-burst | Fontaine, jet |

---

## Section 2 : Recettes de Particules

### Impact / Explosion

```gdscript
## impact_particles.gd
extends GPUParticles2D

@export var particle_color: Color = Color.WHITE
@export var particle_count: int = 12
@export var spread_degrees: float = 45.0
@export var min_velocity: float = 150.0
@export var max_velocity: float = 300.0

func _ready():
    emitting = false
    one_shot = true
    explosiveness = 1.0
    amount = particle_count
    lifetime = 0.3

    # Material — OBLIGATOIRE
    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(1, 0, 0)
    mat.spread = spread_degrees
    mat.initial_velocity_min = min_velocity
    mat.initial_velocity_max = max_velocity
    mat.gravity = Vector3.ZERO
    mat.damping_min = 100.0
    mat.damping_max = 200.0
    mat.scale_min = 0.5
    mat.scale_max = 1.5
    mat.color = particle_color
    process_material = mat

    # Texture basique
    var img = Image.create(8, 8, false, Image.FORMAT_RGBA8)
    img.fill(Color.WHITE)
    texture = ImageTexture.create_from_image(img)

func emit_at(pos: Vector2, direction: Vector2 = Vector2.RIGHT):
    global_position = pos
    rotation = direction.angle()
    restart()
    emitting = true
```

### Traînées de vent (Wind Trails)

```gdscript
## wind_trail.gd
extends GPUParticles2D

func _ready():
    amount = 20
    lifetime = 0.8
    explosiveness = 0.0  # Continu

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(1, 0, 0)
    mat.spread = 10.0
    mat.initial_velocity_min = 100.0
    mat.initial_velocity_max = 150.0
    mat.gravity = Vector3.ZERO

    # Trail
    trail_enabled = true
    trail_lifetime = 0.3

    # Gradient pour fade
    var gradient = GradientTexture1D.new()
    gradient.gradient = Gradient.new()
    gradient.gradient.set_color(0, Color(1, 1, 1, 0.5))
    gradient.gradient.set_color(1, Color(1, 1, 1, 0.0))
    mat.color_ramp = gradient

    process_material = mat
```

### Pluie avec éclaboussures (Sub-Emitter)

**Particule 1 — Gouttes**

```gdscript
## rain.gd
extends GPUParticles2D

@export var splash_scene: PackedScene  # La scène des splashes

func _ready():
    amount = 100
    lifetime = 1.0
    explosiveness = 0.0

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(0, 1, 0)  # Vers le bas
    mat.spread = 5.0
    mat.initial_velocity_min = 400.0
    mat.initial_velocity_max = 500.0
    mat.gravity = Vector3(0, 200, 0)

    # Collision pour déclencher les splashes
    mat.collision_mode = ParticleProcessMaterial.COLLISION_RIGID
    mat.collision_bounce = 0.0
    mat.collision_friction = 1.0

    process_material = mat

    # Connecter le signal de collision (Godot 4.3+)
    # Ou utiliser un sub_emitter
```

**Particule 2 — Splashes (Sub-emitter)**

Dans l'inspecteur du GPUParticles2D principal :
- `Sub Emitter` > `Mode` = `At Collision`
- Assigner le GPUParticles2D enfant qui fait le splash

```gdscript
## splash.gd — Le sub-emitter
extends GPUParticles2D

func _ready():
    amount = 6
    lifetime = 0.2
    explosiveness = 1.0
    one_shot = true

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(0, -1, 0)  # Vers le haut
    mat.spread = 60.0
    mat.initial_velocity_min = 50.0
    mat.initial_velocity_max = 100.0
    mat.gravity = Vector3(0, 300, 0)
    mat.damping_min = 50.0

    process_material = mat
```

### Feux d'artifice (Multi-stage)

```gdscript
## firework_rocket.gd — Étape 1 : la fusée
extends GPUParticles2D

func _ready():
    amount = 1
    lifetime = 1.5
    explosiveness = 1.0
    one_shot = true

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(0, -1, 0)  # Vers le haut
    mat.spread = 0.0
    mat.initial_velocity_min = 300.0
    mat.initial_velocity_max = 350.0
    mat.gravity = Vector3(0, 100, 0)  # Ralentit en montant

    # Sub-emitter à la fin de vie
    # Mode = At End
    sub_emitter = $Explosion.get_path()

    process_material = mat
```

```gdscript
## firework_explosion.gd — Étape 2 : l'explosion
extends GPUParticles2D

func _ready():
    amount = 50
    lifetime = 1.0
    explosiveness = 1.0
    one_shot = true

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(1, 0, 0)
    mat.spread = 180.0  # Toutes directions
    mat.initial_velocity_min = 100.0
    mat.initial_velocity_max = 200.0
    mat.gravity = Vector3(0, 100, 0)
    mat.damping_min = 80.0
    mat.damping_max = 120.0  # Freinage (résistance de l'air)

    # Couleur qui change
    var gradient = GradientTexture1D.new()
    gradient.gradient = Gradient.new()
    gradient.gradient.set_color(0, Color.YELLOW)
    gradient.gradient.set_color(1, Color.RED)
    mat.color_ramp = gradient

    process_material = mat
```

### Feuilles qui tombent (Turbulence)

```gdscript
## falling_leaves.gd
extends GPUParticles2D

func _ready():
    amount = 30
    lifetime = 4.0
    explosiveness = 0.0

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(0.2, 1, 0)  # Légèrement en diagonale
    mat.spread = 20.0
    mat.initial_velocity_min = 20.0
    mat.initial_velocity_max = 40.0
    mat.gravity = Vector3(0, 30, 0)

    # Turbulence = vent imprévisible
    mat.turbulence_enabled = true
    mat.turbulence_noise_strength = 2.0
    mat.turbulence_noise_speed = 0.5
    mat.turbulence_noise_scale = 1.0

    # Rotation sur elles-mêmes
    mat.angular_velocity_min = -180.0
    mat.angular_velocity_max = 180.0

    process_material = mat
```

---

## Section 3 : Shaders 2D

### Hit Flash Shader

```gdshader
// hit_flash.gdshader
shader_type canvas_item;

uniform float flash_intensity : hint_range(0.0, 1.0) = 0.0;
uniform vec4 flash_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    COLOR = mix(tex, flash_color, flash_intensity);
    COLOR.a = tex.a;
}
```

### Sway Shader (Vent sur végétation)

Le haut du sprite bouge, le bas (racines) reste fixe.

```gdshader
// sway.gdshader
shader_type canvas_item;

uniform float speed : hint_range(0.0, 10.0) = 2.0;
uniform float amplitude : hint_range(0.0, 50.0) = 10.0;
uniform float instance_offset : hint_range(0.0, 100.0) = 0.0;  // Randomiser par instance

void vertex() {
    // Le masque UV : bas (UV.y=1) = 0, haut (UV.y=0) = 1
    float mask = 1.0 - UV.y;

    // Mouvement sinusoïdal
    float sway = sin(TIME * speed + instance_offset) * amplitude * mask;

    VERTEX.x += sway;
}
```

**Important** : Cocher "Local to Scene" sur le material, puis dans le script :

```gdscript
func _ready():
    # Chaque buisson a un offset différent
    material.set_shader_parameter("instance_offset", randf() * 100.0)
```

### Dissolve Shader (Mort / Disparition)

```gdshader
// dissolve.gdshader
shader_type canvas_item;

uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform float edge_width : hint_range(0.0, 0.2) = 0.05;
uniform vec4 edge_color : source_color = vec4(1.0, 0.5, 0.0, 1.0);
uniform sampler2D noise_texture;

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    float noise = texture(noise_texture, UV).r;

    // Dissolve basé sur le bruit
    float threshold = progress;

    if (noise < threshold) {
        discard;
    } else if (noise < threshold + edge_width) {
        // Bord lumineux
        COLOR = edge_color;
        COLOR.a = tex.a;
    } else {
        COLOR = tex;
    }
}
```

### Outline Shader

```gdshader
// outline.gdshader
shader_type canvas_item;

uniform vec4 outline_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float outline_width : hint_range(0.0, 10.0) = 1.0;

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    vec2 size = TEXTURE_PIXEL_SIZE * outline_width;

    float outline = texture(TEXTURE, UV + vec2(-size.x, 0)).a;
    outline += texture(TEXTURE, UV + vec2(size.x, 0)).a;
    outline += texture(TEXTURE, UV + vec2(0, -size.y)).a;
    outline += texture(TEXTURE, UV + vec2(0, size.y)).a;

    outline = min(outline, 1.0);

    vec4 final_color = mix(outline_color, tex, tex.a);
    COLOR = final_color;
    COLOR.a = max(tex.a, outline * outline_color.a);
}
```

---

## Section 4 : WorldEnvironment & Post-Processing

### Configuration de base (2D)

1. Ajouter un node `WorldEnvironment` à la scène
2. Créer un `Environment` resource
3. Mettre `Background > Mode` = `Canvas` (pour la 2D)

### ToneMapping

| Mode | Effet | Usage |
|------|-------|-------|
| Linear | Plat, fade | Éviter |
| Reinhard | Naturel | OK |
| Filmic | Cinématique | Recommandé |
| ACES | Standard industrie | Recommandé |

### Glow (Bloom)

Le glow fait briller les zones lumineuses. Le secret : **couleurs RAW** pour contrôler ce qui brille.

**Configuration :**
- `Glow > Enabled` = true
- `Glow > HDR Threshold` = 1.0 ou 1.2
- `Glow > Blend Mode` = `Screen` ou `Additive`
- `Glow > Intensity` = 0.5 à 1.0

**Faire briller UN objet spécifique :**

1. Sur le Sprite/Node qui doit briller, mettre `Modulate` en mode **RAW**
2. Mettre les valeurs RGB > 1.0 (ex: R=3, G=0.2, B=0.2 pour un rouge intense)
3. Seul cet objet dépassera le seuil HDR et brillera

```gdscript
## Dans le script de l'objet qui doit briller
func set_glow(intensity: float):
    # intensity > 1.0 pour déclencher le glow
    modulate = Color(intensity, intensity, intensity, 1.0)
```

### Vignette

Assombrit les bords de l'écran. Donne un effet cinématique.

- `Adjustments > Enabled` = true
- Utiliser un shader de vignette custom si besoin de contrôle

---

## Section 5 : Éclairage 2D (PointLight2D)

### Règles de base

1. **Toujours une texture** — ne pas laisser vide. Un cercle flou pour une ampoule, un cône pour une torche
2. **Energy** — l'intensité. 0.5-1.0 pour ambiance, 1.5+ pour sources fortes
3. **Color** — la teinte. Bleu froid pour nuit, orange chaud pour torches
4. **Range** — la portée. Attention aux overlaps

### Ombres 2D

1. Sur le `PointLight2D` : `Shadow > Enabled` = true
2. Sur les objets opaques : ajouter un `LightOccluder2D` enfant
3. Dessiner le polygone d'occlusion (ou le générer depuis le sprite)

```gdscript
## Générer un occluder depuis un sprite
func _ready():
    var occluder = LightOccluder2D.new()
    var polygon = OccluderPolygon2D.new()

    # Polygone simple rectangle
    var size = $Sprite.texture.get_size() * $Sprite.scale
    polygon.polygon = PackedVector2Array([
        Vector2(-size.x/2, -size.y/2),
        Vector2(size.x/2, -size.y/2),
        Vector2(size.x/2, size.y/2),
        Vector2(-size.x/2, size.y/2)
    ])

    occluder.occluder = polygon
    add_child(occluder)
```

### Pattern — Lumière qui suit le joueur

```gdscript
## player_light.gd
extends PointLight2D

@export var player_path: NodePath
var player: Node2D

func _ready():
    player = get_node(player_path)

func _process(_delta):
    global_position = player.global_position
```

### Pattern — Lumière qui clignote (torche)

```gdscript
## flickering_light.gd
extends PointLight2D

@export var base_energy: float = 1.0
@export var flicker_amount: float = 0.2
@export var flicker_speed: float = 10.0

var noise := FastNoiseLite.new()
var noise_y: float = 0.0

func _ready():
    noise.seed = randi()
    noise.frequency = 0.5

func _process(delta):
    noise_y += delta * flicker_speed
    var flicker = noise.get_noise_1d(noise_y) * flicker_amount
    energy = base_energy + flicker
```

---

## Section 6 : Y-Sort (Profondeur 2.5D)

Pour que le joueur passe devant/derrière les objets selon sa position Y.

### Configuration

1. Sur le node parent (ex: Level) : `Y Sort Enabled` = true
2. Sur chaque entité (joueur, arbres, ennemis) : `Y Sort Enabled` = true aussi si ils ont des enfants à trier

### Point d'origine

Le tri se fait sur la position Y du node. Pour un personnage, le point d'origine doit être **aux pieds**, pas au centre.

**Sur le Sprite** : ajuster `Offset.y` pour que l'origine soit aux pieds.

```gdscript
## Ou via code
$Sprite.offset.y = -sprite_height / 2
```

---

## Section 7 : Performance

### Budgets indicatifs

| Ressource | Par effet | Total écran |
|-----------|-----------|-------------|
| Particules actives | 50-100 | ≤ 500 |
| PointLight2D avec ombres | 1-2 | ≤ 5 |
| Shaders custom | - | ≤ 10 simultanés |
| Draw calls | - | ≤ 100 |

### Optimisations

1. **Particules** : réduire `amount`, augmenter `scale` pour compenser
2. **Lumières** : désactiver les ombres sur les petites lumières
3. **Shaders** : éviter les boucles, minimiser les `texture()` calls
4. **Culling** : désactiver les effets hors écran

```gdscript
## Auto-disable quand hors écran
func _ready():
    var notifier = VisibleOnScreenNotifier2D.new()
    add_child(notifier)
    notifier.screen_exited.connect(func(): emitting = false)
    notifier.screen_entered.connect(func(): emitting = true)
```

---

## Section 8 : Checklist Review

| Pattern | Question | Fix si non |
|---------|----------|------------|
| **Particules** | `process_material` configuré ? | Créer ParticleProcessMaterial |
| **Particules** | Texture assignée ? | Créer ou assigner une texture |
| **Glow** | Utilise des couleurs RAW pour cibler ? | Passer le Modulate en RAW, valeurs > 1 |
| **Glow** | HDR Threshold configuré ? | Mettre à 1.0 ou 1.2 |
| **Lumières** | Texture de cookie assignée ? | Créer une texture de lumière |
| **Shaders** | Performances OK ? | Profiler, simplifier si lag |
| **Y-Sort** | Point d'origine aux pieds ? | Ajuster Sprite.offset |

---

## Section 9 : Pièges courants

| Piège | Symptôme | Solution |
|-------|----------|----------|
| GPUParticles2D sans material | Rien ne s'affiche | Créer ParticleProcessMaterial |
| Glow sur tout | Image surexposée | Utiliser HDR Threshold + couleurs RAW |
| Trop de lumières avec ombres | FPS drop | Limiter à 3-5 lumières avec ombres |
| Y-Sort avec origine au centre | Tri incorrect | Offset le sprite pour que l'origine soit aux pieds |
| Shader sway sans masque UV | Tout le sprite bouge | Multiplier par `(1.0 - UV.y)` |
| Sub-emitter pas configuré | Pas de splash/explosion | Vérifier le mode (At Collision, At End) |

---

## See also

| Skill | Quand l'utiliser |
|-------|-----------------|
| `/godot-juice` | Game feel (screenshake, freeze frame, squash & stretch, trails, damage numbers) |
| `/godot-behavior` | Feedback UI (hover, focus, pressed), tweens UI |
| `/godot-theme` | Design tokens, palette de couleurs, fonts |
| `/godot-debug` | Si les VFX causent des bugs (performance, crashes) |
