---
description: Create visual effects — particles, shaders, lighting, WorldEnvironment
user-invocable: false
---
# /godot-vfx — Effets visuels : particules, shaders, lighting

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

- [Philosophie](#philosophie)
- [Workflow VFX](#workflow-vfx)
- [Architecture VFX](#architecture-vfx)

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. Chaque effet doit répondre à : what (quel feedback), where (position), when (timing), how much (intensité), who (source)
- **Lisible en 200ms** — si l'effet n'est pas compris en un regard, c'est du bruit. Silhouette, timing, contraste avant tout
- **Performance** — le GPU a des limites, les respecter
- **Cohérence** — même style visuel partout
- **Noise + Scroll = 90% des effets magiques** — eau, feu, lasers, portails = 2 textures de bruit qui défilent à des vitesses différentes. Avant de coder un shader complexe, essayer noise + scroll
- **Anti-pattern "glow partout"** — l'oeil s'habitue. Si tout brille, rien ne brille. Le glow est un accent, pas un style — réserver aux éléments critiques (collectibles, projectiles, UI focus)

### Composition par couches

**Un bon VFX n'est jamais un seul node.** C'est une composition de 3-5 couches superposées, chacune avec son rôle. Ne jamais créer un GPUParticles2D unique pour un effet complexe — le décomposer.

| Couche | Rôle | Timing | Exemple node |
|--------|------|--------|-------------|
| **Flash** | Impact immédiat, attire l'oeil | Instantané (1-2 frames) | PointLight2D (energy burst) ou Sprite blanc |
| **Sparks / Debris** | Énergie, direction de l'impact | Rapide (0.1-0.3s), haute vélocité | GPUParticles2D (explosiveness=1, amount=8-15) |
| **Core** | Corps de l'effet (feu, magie, sang) | Moyen (0.3-0.8s) | GPUParticles2D principal |
| **Smoke / Residue** | Ce qui reste après, settling | Lent (0.5-2s), gravité, damping élevé | GPUParticles2D (amount faible, scale large) |
| **Shockwave** | Onde qui s'étend, feedback spatial | Rapide (0.2-0.4s) | Sprite + scale tween ou shader distortion |

**Pattern d'implémentation :**
```
EffectRoot (Node2D)
├── Flash (PointLight2D ou Sprite) — one_shot tween
├── Sparks (GPUParticles2D) — one_shot, explosiveness=1
├── Fire (GPUParticles2D) — one_shot, corps de l'effet
├── Smoke (GPUParticles2D) — one_shot, plus lent
└── Shockwave (Sprite2D) — scale tween 0→max + fade
```

Chaque couche a son propre `lifetime`, `blend_mode` (ADD pour feu/sparks, MIX pour fumée), et timing. Le flash et les sparks partent en premier, la fumée persiste.

**Blend modes :**
- **ADD** — feu, magie, étincelles, tout ce qui brille. Ajoute de la luminosité, ne noircit jamais
- **MIX** — fumée, poussière, objets solides. Couvre ce qui est derrière

---

## Workflow VFX

6 étapes de production — ne pas sauter d'étape :

| # | Étape | Livrable | Temps |
|---|-------|----------|-------|
| 1 | **Prototype cheap** | Sprite + couleur + timing. Pas de shader, pas de particles | 10 min |
| 2 | **Lisibilité** | Silhouette lisible, timing clair, contraste suffisant. Si c'est pas lisible en 200ms, simplifier | 15 min |
| 3 | **Shader / Particles** | Distorsion, glow, dissolve, trails. Seulement quand l'intention est claire | 30 min+ |
| 4 | **Perf check** | Overdraw, samples, taille textures. Tester sur la config cible | 5 min |
| 5 | **Industrialiser** | Params exposés (`@export`), scène réutilisable, pooling si fréquent | 15 min |
| 6 | **Documenter** | Screenshot + params clés dans un commentaire en tête du script | 5 min |

Le piège classique : sauter directement à l'étape 3 sans avoir validé le timing et la lisibilité. Un dissolve shader magnifique avec un timing foireux = un effet raté.

---

## Architecture VFX

**Un VFX = une scène dédiée.** Jamais des nodes perdus dans Player ou Enemy — ça ne scale pas, ça ne se réutilise pas.

### Template structure
```
VFX_Effect.tscn (Node2D root)
├── GPUParticles2D (burst/trail)
├── Sprite2D (ring/shockwave, ShaderMaterial)
├── AudioStreamPlayer2D
├── AnimationPlayer (chef d'orchestre)
└── PointLight2D (optionnel)
```

### AnimationPlayer = chef d'orchestre

L'AnimationPlayer pilote **tout le timing** du VFX en une seule timeline :
- Frame 0.0 : `emitting = true` sur les particles, son play
- Frame 0.05 : shader_parameter `flash_intensity = 1.0`
- Frame 0.1 : screenshake signal
- Frame 0.3 : fade out particles, `flash_intensity → 0.0`
- Frame 0.5 : `queue_free()` ou retour au pool

Un AnimationPlayer bien réglé remplace 30 lignes de code timing dans `_process`.

### Pooling

Obligatoire pour les VFX fréquents (hits, dash, footsteps). Pas d'allocations/destructions massives en combat.

```gdscript
## vfx_pool.gd
var _pool: Array[Node2D] = []
var _scene: PackedScene

func _init(scene: PackedScene, size: int = 10) -> void:
    _scene = scene
    for i in size:
        var instance := scene.instantiate() as Node2D
        instance.visible = false
        _pool.append(instance)

func get_instance() -> Node2D:
    for vfx in _pool:
        if not vfx.visible:
            vfx.visible = true
            return vfx
    # Pool exhausted — grow
    var instance := _scene.instantiate() as Node2D
    instance.visible = true
    _pool.append(instance)
    return instance
```

---

## 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 |

### CPUParticles2D — Fallback

Si le GPU n'est pas disponible (mobile bas de gamme, compatibilité), utiliser `CPUParticles2D`. Mêmes paramètres, mais calculé sur CPU. Godot peut convertir automatiquement : clic droit sur GPUParticles2D > **Convert to CPUParticles2D**.

### Paramètres souvent oubliés

| Paramètre | Effet | Quand l'utiliser |
|-----------|-------|-----------------|
| **Preprocess** | Pré-remplit les particules au chargement | Fumée/feu déjà visible au spawn (mettre 2.0-5.0) |
| **Visibility Rect** | Zone de rendu des particules | Toujours cliquer **Generate Visibility Rect** après config |
| **Local Coords** | Particules suivent l'émetteur | Off = traînées (fusée), On = aura (torche portée) |
| **Lifetime Randomness** | Variation de durée de vie (0-1) | Effets naturels : 0.2-0.4 (feu, feuilles) |
| **Hue Variation** | Variation de teinte automatique | Particules multicolores en un seul param (ex: confettis) |
| **Draw Order** | Index (ordre d'émission) ou Lifetime (les plus vieilles devant) | Lifetime pour fumée (couches), Index pour la plupart |

---

## 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
```

### Orbite / Magic Circle

Particules qui tournent autour d'un point — cercle magique, aura, shield.

```gdscript
## orbit_particles.gd
extends GPUParticles2D

func _ready():
    amount = 20
    lifetime = 2.0
    explosiveness = 0.0

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(1, 0, 0)
    mat.spread = 0.0
    mat.initial_velocity_min = 0.0
    mat.initial_velocity_max = 0.0

    # Orbit = the key
    mat.orbit_velocity_min = 0.5
    mat.orbit_velocity_max = 0.5

    # Radial acceleration keeps them at distance
    mat.radial_accel_min = -50.0
    mat.radial_accel_max = -50.0

    # Emission ring
    mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING
    mat.emission_ring_radius = 60.0
    mat.emission_ring_inner_radius = 55.0
    mat.emission_ring_height = 0.0
    mat.emission_ring_axis = Vector3(0, 0, 1)

    mat.scale_min = 0.3
    mat.scale_max = 0.6

    process_material = mat
```

### Flipbook Animation (Fumée / Feu animé)

Pour des particules avec une spritesheet animée (fumée, feu stylisé).

1. Créer une spritesheet (ex: 4x4 frames de fumée)
2. Sur le GPUParticles2D, assigner la spritesheet comme `Texture`
3. Configurer **Animation** dans le ParticleProcessMaterial :

```gdscript
## flipbook_particles.gd
extends GPUParticles2D

func _ready():
    amount = 8
    lifetime = 1.5
    explosiveness = 0.0

    # Flipbook config
    texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST  # Pixel art

    var mat = ParticleProcessMaterial.new()
    mat.direction = Vector3(0, -1, 0)
    mat.spread = 15.0
    mat.initial_velocity_min = 30.0
    mat.initial_velocity_max = 50.0
    mat.gravity = Vector3.ZERO

    # Animation — parcourt la spritesheet
    mat.anim_speed_min = 1.0
    mat.anim_speed_max = 1.0

    process_material = mat

    # IMPORTANT: CanvasItemMaterial pour le blend mode
    var canvas_mat = CanvasItemMaterial.new()
    canvas_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD  # Evite les fonds noirs
    material = canvas_mat
```

> **Piège** : sans `BLEND_MODE_ADD` sur le CanvasItemMaterial, les frames avec un fond noir affichent des carrés noirs autour des particules.

### Ghost Trail (Traînée fantôme — Dash)

Crée des after-images du joueur qui s'estompent. L'approche la plus fiable : **spawner des Sprite2D copies**, pas des particules (les particules ne peuvent pas facilement sync la frame d'animation courante).

```gdscript
## ghost_trail.gd — Composant à attacher au joueur
extends Node2D

@export var ghost_color: Color = Color(0.5, 0.5, 1.0, 0.6)
@export var ghost_lifetime: float = 0.4
@export var spawn_interval: float = 0.05

var _timer: float = 0.0
var _active: bool = false

func start():
    _active = true
    _timer = 0.0

func stop():
    _active = false

func _process(delta: float) -> void:
    if not _active:
        return
    _timer -= delta
    if _timer <= 0.0:
        _timer = spawn_interval
        _spawn_ghost()

func _spawn_ghost() -> void:
    var sprite: AnimatedSprite2D = get_parent().get_node("AnimatedSprite2D")
    var ghost := Sprite2D.new()
    ghost.texture = sprite.sprite_frames.get_frame_texture(
        sprite.animation, sprite.frame)
    ghost.global_position = get_parent().global_position
    ghost.flip_h = sprite.flip_h
    ghost.modulate = ghost_color
    get_tree().current_scene.add_child(ghost)

    # Tween lié à l'arbre (pas au ghost, sinon il meurt avec)
    var tween: Tween = get_tree().create_tween()
    tween.tween_property(ghost, "modulate:a", 0.0, ghost_lifetime)
    tween.tween_callback(ghost.queue_free)
```

> **Pourquoi pas GPUParticles2D ?** — `INSTANCE_CUSTOM` ne permet pas de passer dynamiquement la frame d'animation courante par particule. Le sprite clone est plus simple, plus fiable, et respecte la frame exacte.

### Sprite Sheet Random Particles

Un seul émetteur affiche des visuels variés (débris, feuilles, confettis) à partir d'une grille d'images. Pas de shader custom — tout passe par `CanvasItemMaterial`.

**Setup :**
1. **Texture** : sprite sheet (ex: 4 feuilles différentes, 4 colonnes × 1 ligne)
2. **Material** : `CanvasItemMaterial` sur le GPUParticles2D
   - `Particles Animation` = ON
   - `H Frames` = nombre de colonnes (ex: 4)
   - `V Frames` = nombre de lignes (ex: 1)
3. **ParticleProcessMaterial** → onglet Animation :
   - `Anim Offset Min` = 0
   - `Anim Offset Max` = 1
   - `Anim Speed Min/Max` = 0 (pas d'animation, juste un choix aléatoire)

```gdscript
## random_sprite_particles.gd
extends GPUParticles2D

@export var sprite_sheet: Texture2D
@export var h_frames: int = 4
@export var v_frames: int = 1

func _ready():
    texture = sprite_sheet

    # CanvasItemMaterial pour le découpage sprite sheet
    var canvas_mat := CanvasItemMaterial.new()
    canvas_mat.particles_animation = true
    canvas_mat.particles_anim_h_frames = h_frames
    canvas_mat.particles_anim_v_frames = v_frames
    canvas_mat.particles_anim_loop = false
    material = canvas_mat

    # Process material — offset aléatoire = frame aléatoire
    var mat := ParticleProcessMaterial.new()
    mat.anim_offset_min = 0.0
    mat.anim_offset_max = 1.0
    mat.anim_speed_min = 0.0
    mat.anim_speed_max = 0.0
    # ... configurer direction, velocity, gravity selon l'effet voulu
    process_material = mat
```

> **Astuce** : `Anim Offset Max = 1` fait que chaque particule pioche une frame aléatoire à sa naissance. `Anim Speed = 0` fige la frame (pas d'animation). Pour des particules qui jouent leur animation, mettre `Anim Speed > 0`.

---

## Section 3 : Shaders 2D

### Shader Basics — Primer rapide

Avant les recettes, les fondamentaux d'un shader 2D Godot :

```gdshader
shader_type canvas_item;  // Obligatoire pour les nodes 2D

// Uniforms = paramètres contrôlables depuis GDScript
uniform float speed : hint_range(0.0, 10.0) = 1.0;
uniform vec4 tint : source_color = vec4(1.0);
uniform sampler2D noise_tex;

void fragment() {
    // UV = coordonnées de texture (0,0 = haut-gauche, 1,1 = bas-droite)
    // TEXTURE = la texture du sprite
    // COLOR = la couleur finale du pixel
    vec4 tex = texture(TEXTURE, UV);
    COLOR = tex * tint;
}

void vertex() {
    // VERTEX = position du sommet (déformation géométrique)
    VERTEX.x += sin(TIME * speed) * 5.0;
}
```

**Appliquer depuis GDScript** : `sprite.material.set_shader_parameter("speed", 2.0)`

**Convertir un material existant** : dans l'inspecteur, clic sur le material > **Convert to ShaderMaterial** pour voir le shader généré.

### 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)

### Golden rule

**Les shaders ne produisent jamais de lumière.** Un shader canvas_item définit la surface (couleur, texture, grain). Le système de lumière Godot (CanvasModulate + PointLight2D) multiplie cette surface par les lumières. Mixer un glow shader avec un PointLight2D crée un double-bubble visible — deux sources qui falloff à des taux différents.

### CanvasModulate

Le node qui définit l'obscurité ambiante. Tout ce qui n'est pas éclairé par un PointLight2D reçoit cette couleur.

| Paramètre | Valeur recommandée | Pourquoi |
|-----------|-------------------|----------|
| Color | `(0.0, 0.0, 0.0, 1.0)` — noir pur | Le maximum pour un effet "halo dans le noir" |
| Max toléré | `(0.02, 0.02, 0.02)` | Au-delà, l'ambient laisse voir le bord du halo par contraste |

### 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

### Règles gradient PointLight2D

La texture du PointLight2D contrôle le falloff. Godot lit la **luminance RGB**, pas l'alpha.

| Règle | Pourquoi |
|-------|----------|
| Utiliser **RGB opaque** (blanc → noir), pas alpha | Les gradients alpha causent des ring artifacts. PointLight2D lit la luminance |
| Utiliser **GRADIENT_INTERPOLATE_CUBIC** | L'interpolation linéaire crée du banding visible et un falloff plat |
| **Max 3 stops** avec cubic | Trop de stops = spline overshoot → anneaux sombres visibles entre chaque paire |
| Texture **512x512 minimum** | Plus petit = banding visible quand `texture_scale` est élevé |
| Le gradient doit atteindre **exactement Color.BLACK** au bord | Toute luminosité résiduelle au bord crée une coupure nette (cercle dur) |

### Erreurs courantes qui créent l'effet "bulle"

1. **Shader glow + PointLight2D** — deux sources qui overlap à des taux différents. Le smoothstep du shader et le gradient du light ne matchent pas → anneau visible
2. **Gradient alpha** — `Color(1,1,1, 0.5)` au lieu de `Color(0.5, 0.5, 0.5)`. L'alpha se comporte différemment du RGB dans PointLight2D
3. **Trop de stops gradient** — cubic avec 8+ stops = overshoot du spline (bandes sombres)
4. **CanvasModulate pas assez noir** — même `(0.03, 0.03, 0.03)` suffit à rendre le bord du halo visible par contraste

### 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) |
| Flipbook avec fond noir | Carrés noirs autour des particules | `BLEND_MODE_ADD` sur le CanvasItemMaterial |
| Visibility Rect trop petit | Particules disparaissent au bord de l'écran | Particles > **Generate Visibility Rect** après config |
| Preprocess = 0 | Effet vide au chargement de la scène (fumée, feu) | Mettre `preprocess` > 0 (ex: 2.0 à 5.0) |
| Shader glow + PointLight2D | Double-bubble / anneau visible | Le shader définit la surface, le PointLight2D éclaire. Jamais les deux |
| Gradient PointLight2D en alpha | Ring artifacts, transitions dures | Utiliser RGB opaque (blanc→noir), pas de transparence alpha |
| Gradient trop de stops + cubic | Anneaux sombres entre les stops (spline overshoot) | Max 3 stops avec GRADIENT_INTERPOLATE_CUBIC |
| `floor(TIME * speed)` dans shader | Pixels carrés qui flickent (frame-snapping) | Utiliser `sin(TIME * speed)` / `cos()` pour animation smooth |
| CanvasModulate > (0.02, 0.02, 0.02) | Bord du halo visible, lumière ambient non voulue | Noir pur ou quasi-pur pour un vrai effet "darkness" |
| Mobile renderer + gradients subtils | Banding visible (précision couleur inférieure) | Passer en Forward+ ou texture PNG pré-calculée avec dithering |
| ColorRect fullscreen shader = blanc | Flash blanc quand le shader a un hiccup GPU | Base color = `Color(0, 0, 0, 0)` dans `_ready()`. Aussi : hit flash `Color.WHITE * 3.0` + HDR = overbright sur clusters → limiter à `Color(2.0, 2.0, 2.0)` |
| Vignette/glow trop fort au départ | Écran quasi-noir, diagnostic difficile | Toujours démarrer les VFX à 20% d'intensité et augmenter. Alpha 0.85 + radius 0.45 + CanvasModulate = opaque. Commencer à alpha 0.35, radius 0.6 |
| Particles dupliquées sans Make Unique | Modifier une modifie TOUTES les copies | "Make Unique" **récursif** sur le ProcessMaterial après duplication. Sinon le material est partagé entre toutes les instances |
| 3 textures séparées pour un shader | Gaspillage de samplers, 3 texture() calls | **Texture Packing RGBA** — combiner forme (R), noise (G), masque (B) en 1 texture. Accès : `texture(tex, UV).g` pour le noise. 1 sampler au lieu de 3 |

---

## See also

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

| Recette | Contenu marinade |
|---------|-----------------|
| `recipes/shader_cookbook_2d.md` | 13 techniques shader (dissolve, outline, CRT, palette swap, shockwave...) avec valeurs testées |
| `recipes/particle_presets.md` | 7 presets GPUParticles2D (dust, explosion, trail, fire, rain...) avec values + layering |
| `recipes/lights_and_shadows.md` | Darkness values de jeux publiés, posterization shader, gradient PointLight2D |
| `recipes/color_and_mood.md` | Palettes limitées, Dead Cells saturation, color grading shaders, mood par biome |
