# Godot 4 --- Shader Cookbook 2D

> **Purpose**: Reference document for Claude Code. Tested shader techniques for 2D games --- specific values, creative combinations, and mistakes that kill the feeling. The marinade, not "how to write a shader."

---

## 1. Dissolve Effects

### The Noise-Driven Dissolve

The workhorse of death/spawn/transition effects. A noise texture controls which pixels disappear first.

```gdshader
shader_type canvas_item;

uniform sampler2D dissolve_texture : source_color;
uniform float dissolve_value : hint_range(0, 1);
uniform float burn_size : hint_range(0.0, 1.0, 0.01);
uniform vec4 burn_color : source_color;

void fragment() {
    vec4 main_texture = texture(TEXTURE, UV);
    vec4 noise_texture = texture(dissolve_texture, UV);

    // Avoid burn dot artifact at 0.0 and 1.0
    float burn_size_step = burn_size * step(0.001, dissolve_value) * step(dissolve_value, 0.999);
    float threshold = smoothstep(noise_texture.x - burn_size_step, noise_texture.x, dissolve_value);
    float border = smoothstep(noise_texture.x, noise_texture.x + burn_size_step, dissolve_value);

    COLOR.a *= threshold;
    COLOR.rgb = mix(burn_color.rgb, main_texture.rgb, border);
}
```

**Tested values**:

| Parameter | Value | Feel |
|-----------|-------|------|
| `burn_size` | 0.04 | Tight ember edge --- Celeste-style death |
| `burn_size` | 0.15 | Wide fire glow --- dramatic boss dissolve |
| `burn_size` | 0.3+ | Entire sprite glows before vanishing --- magical/holy |
| `burn_color` | `(1.0, 0.3, 0.0, 1.0)` | Fire/ember orange --- standard enemy death |
| `burn_color` | `(0.5, 0.0, 1.0, 1.0)` | Purple corruption --- void/shadow entities |
| `burn_color` | `(1.0, 1.0, 1.0, 1.0)` | White-hot --- purification, holy damage |

**Speed curves that feel right** (animated via Tween in GDScript):

```gdscript
# Fast death (Celeste-like) --- 0.3s total
var tween := create_tween()
tween.tween_property(material, "shader_parameter/dissolve_value", 1.0, 0.3)\
    .from(0.0).set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_QUAD)

# Dramatic death (boss) --- 1.2s with slow start
var tween := create_tween()
tween.tween_property(material, "shader_parameter/dissolve_value", 1.0, 1.2)\
    .from(0.0).set_ease(Tween.EASE_IN).set_trans(Tween.TRANS_EXPO)

# Spawn-in (reverse dissolve) --- 0.5s, decelerating
var tween := create_tween()
tween.tween_property(material, "shader_parameter/dissolve_value", 0.0, 0.5)\
    .from(1.0).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
```

### Fire Dissolve (Directional)

Burns from bottom to top. Uses `UV.y` to bias the noise --- bottom pixels dissolve first.

```gdshader
shader_type canvas_item;

uniform sampler2D noise_texture;
uniform sampler2D burn_texture; // GradientTexture1D: black -> orange -> yellow -> white
uniform float integrity : hint_range(0.0, 1.0) = 1.0;
uniform float burn_size : hint_range(1.0, 1.5) = 1.3;

float inverse_lerp(float a, float b, float v) {
    return (v - a) / (b - a);
}

void fragment() {
    float noise = texture(noise_texture, UV).r * UV.y;
    vec4 base_color = texture(TEXTURE, UV) * step(noise, integrity);
    vec2 burn_uv = vec2(inverse_lerp(integrity, integrity * burn_size, noise), 0.0);
    vec4 burn_color = texture(burn_texture, burn_uv) * step(noise, integrity * burn_size);
    COLOR = mix(burn_color, base_color, base_color.a);
}
```

**Direction control**: Replace `UV.y` with `(1.0 - UV.y)` for top-down burn, `UV.x` for left-to-right, `(1.0 - UV.x)` for right-to-left.

**Noise texture setup**: NoiseTexture2D, Simplex Smooth, frequency 2.0--4.0. Lower frequency = larger dissolve chunks (feels like disintegration). Higher frequency = finer grain (feels like burning paper).

### Piege: Dissolve + Particles Timing

When combining dissolve with GPUParticles2D for debris/sparks, the particles must START before the dissolve is visually noticeable. Start particles at `dissolve_value ~0.05`, not at 0.0 (looks like particles come from nowhere) and not at 0.3 (too late, dissolve already visible without any particle anticipation).

---

## 2. Hit Flash Techniques

### Level 1: Binary White Flash (Simple)

Replaces all pixels with white. Preserves alpha. The minimum viable hit feedback.

```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, vec4(flash_color.rgb, tex.a), flash_intensity);
}
```

**Why `mix()` over `if/else`**: GPU branching (if statements) kills shader performance on older GPUs. The `mix()` approach is branchless and lets you animate `flash_intensity` smoothly instead of popping on/off.

**Piege: `set_shader_parameter` before Tween**: Godot 4 returns null if you tween a shader parameter that was never set via script. Always initialize:

```gdscript
# MUST do this before any tween
material.set_shader_parameter("flash_intensity", 0.0)

# Then tween works
var tween := create_tween()
tween.tween_property(material, "shader_parameter/flash_intensity", 1.0, 0.05)
tween.tween_property(material, "shader_parameter/flash_intensity", 0.0, 0.1)
```

### Level 2: Additive Flash (Beyond White)

White flash is readable but flat. Additive flash ADDS color to the existing sprite, preserving silhouette detail:

```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);
    // Additive: sprite detail visible through the flash
    COLOR.rgb = tex.rgb + flash_color.rgb * flash_intensity;
    COLOR.a = tex.a;
}
```

**Tested values for additive flash**:

| Situation | `flash_color` | `flash_intensity` | Duration |
|-----------|---------------|-------------------|----------|
| Light hit | White `(1,1,1)` | 0.6 | 0.05s on, 0.08s off |
| Heavy hit | White `(1,1,1)` | 1.0 | 0.08s on, 0.15s off |
| Poison tick | Green `(0.2,1,0.2)` | 0.4 | 0.03s on, 0.05s off |
| Fire damage | Orange `(1,0.5,0)` | 0.7 | 0.06s on, 0.1s off |
| Critical hit | `(2,2,2)` HDR white | 1.0 | 0.1s on, 0.2s off |

### Level 3: Overbright Flash (HDR)

For critical hits and boss phase transitions. Color values above 1.0 exploit HDR bloom if WorldEnvironment glow is enabled.

```gdshader
shader_type canvas_item;

uniform float flash_intensity : hint_range(0.0, 1.0) = 0.0;
uniform vec3 flash_color = vec3(3.0, 3.0, 3.0); // HDR overbright

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

**Piege: White * 3.0 + HDR = overbright bloom blowout**. Limit to `Color(2.0, 2.0, 2.0)` max unless you want the sprite to become a sun. With WorldEnvironment glow threshold at 1.0, values above 1.0 bloom. At `3.0`, the bloom radius becomes enormous and bleeds into the whole screen.

**Piege: Shared Material = all instances flash**. If multiple enemies share the same ShaderMaterial, setting `flash_intensity` on one flashes them ALL. Fix: `material = material.duplicate()` in `_ready()`, or use `material.resource_local_to_scene = true` on the resource.

### The Flash Stack (Combine for Impact)

A single hit should trigger multiple effects in sequence:

```gdscript
func take_damage(amount: int, source_dir: Vector2) -> void:
    # 1. Freeze frame (hitstop) --- 50-80ms
    Engine.time_scale = 0.05
    await get_tree().create_timer(0.06, true, false, true).timeout
    Engine.time_scale = 1.0

    # 2. White flash --- 50ms on, ramp down 100ms
    var flash_tween := create_tween()
    flash_tween.tween_property(sprite.material, "shader_parameter/flash_intensity", 1.0, 0.0)
    flash_tween.tween_property(sprite.material, "shader_parameter/flash_intensity", 0.0, 0.1)

    # 3. Knockback (direction from source)
    velocity = -source_dir.normalized() * 300.0

    # 4. Screenshake --- 4-6px, 0.15s, diminishing
    camera.start_shake(5.0, 0.15)

    # 5. Chromatic aberration pulse (post-process)
    screen_fx.chromatic_pulse(0.003, 0.2)
```

**Timing rule**: Hitstop BEFORE flash. If you flash first then freeze, the white frame hangs and looks like a bug. Freeze-then-flash feels like "time stopped from the impact."

---

## 3. Outline Shaders

### Efficient Pixel Outline (Round or Square)

Samples neighboring pixels. If any neighbor has alpha and current pixel doesn't, draw outline color.

```gdshader
shader_type canvas_item;

uniform vec4 outline_color : source_color = vec4(0.0, 0.0, 0.0, 1.0);
uniform int type : hint_enum("Disable", "Round", "Square") = 2;
uniform float thickness = 1.0;

const vec2[8] DIRECTIONS = {
    vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0), vec2(0.0, -1.0),
    vec2(1.0, 1.0), vec2(-1.0, 1.0), vec2(-1.0, -1.0), vec2(1.0, -1.0)
};

float gtz(float input) { return max(0.0, sign(input)); }

float check(sampler2D tex, vec2 from, vec2 size) {
    float result = 0.0;
    for (int i = 0; i < 4 * type; i++) {
        result += texture(tex, from + DIRECTIONS[i] * size * thickness).a;
    }
    return gtz(result);
}

void fragment() {
    COLOR = mix(COLOR, outline_color, check(TEXTURE, UV, TEXTURE_PIXEL_SIZE) * (1.0 - gtz(COLOR.a)));
}
```

**Thickness values by resolution**:

| Viewport | Pixel art scale | `thickness` | Result |
|----------|----------------|-------------|--------|
| 320x180 | 1x (no zoom) | 1.0 | 1px outline, crisp |
| 320x180 | 3x zoom | 1.0 | 3 screen-pixels, chunky |
| 640x360 | 2x zoom | 1.0 | 2 screen-pixels, balanced |
| 1920x1080 | hi-res sprites | 2.0--3.0 | Visible without zoom |

**Round vs Square**: Round (4 directions) creates a diamond-shaped outline. Square (8 directions) fills corners. For pixel art, Round is usually better --- Square outlines look thick at diagonals.

### Dynamic Outline Width (Distance-Based)

Outline gets thicker when the object is farther from the player. Useful for highlighting interactable objects across the screen.

```gdshader
shader_type canvas_item;

uniform vec4 outline_color : source_color = vec4(1.0, 0.84, 0.0, 1.0);
uniform float base_thickness = 1.0;
uniform float max_thickness = 4.0;
uniform float distance_factor : hint_range(0.0, 0.01) = 0.003;

// Set from GDScript: distance to player in pixels
uniform float player_distance = 0.0;

float check_outline(sampler2D tex, vec2 uv, vec2 px_size, float thick) {
    float result = 0.0;
    vec2 offsets[8] = {
        vec2(1,0), vec2(0,1), vec2(-1,0), vec2(0,-1),
        vec2(1,1), vec2(-1,1), vec2(-1,-1), vec2(1,-1)
    };
    for (int i = 0; i < 8; i++) {
        result += texture(tex, uv + offsets[i] * px_size * thick).a;
    }
    return clamp(result, 0.0, 1.0);
}

void fragment() {
    float thick = clamp(base_thickness + player_distance * distance_factor, base_thickness, max_thickness);
    float outline = check_outline(TEXTURE, UV, TEXTURE_PIXEL_SIZE, thick) * (1.0 - step(0.001, texture(TEXTURE, UV).a));
    COLOR = mix(COLOR, outline_color, outline);
}
```

### Piege: Outline + Texture Padding

Outlines sample OUTSIDE the sprite's UV bounds. If the sprite texture has no transparent padding, the outline gets clipped at the edges. Fix: add 2--4px transparent border around every sprite in the atlas, or use `Region > Rect` with extra margin.

---

## 4. Screen Distortion Effects

### Shockwave (Expanding Ring Distortion)

A radial distortion ring that expands from an impact point. Combines UV displacement with chromatic aberration.

```gdshader
shader_type canvas_item;

uniform float strength : hint_range(0.0, 0.1, 0.001) = 0.08;
uniform vec2 center = vec2(0.5, 0.5);
uniform float radius : hint_range(0.0, 1.0, 0.001) = 0.25;
uniform float aberration : hint_range(0.0, 1.0, 0.001) = 0.425;
uniform float width : hint_range(0.0, 0.1, 0.0001) = 0.04;
uniform float feather : hint_range(0.0, 1.0, 0.001) = 0.135;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap;

void fragment() {
    vec2 st = SCREEN_UV;
    float aspect_ratio = SCREEN_PIXEL_SIZE.y / SCREEN_PIXEL_SIZE.x;
    vec2 scaled_st = (st - vec2(0.0, 0.5)) / vec2(1.0, aspect_ratio) + vec2(0.0, 0.5);
    vec2 dist_center = scaled_st - center;

    float mask = (1.0 - smoothstep(radius - feather, radius, length(dist_center)))
               * smoothstep(radius - width - feather, radius - width, length(dist_center));

    vec2 offset = normalize(dist_center) * strength * mask;
    vec2 biased_st = scaled_st - offset;
    vec2 abber_vec = offset * aberration * mask;
    vec2 final_st = st * (1.0 - mask) + biased_st * mask;

    vec4 red = texture(SCREEN_TEXTURE, final_st + abber_vec);
    vec4 blue = texture(SCREEN_TEXTURE, final_st - abber_vec);
    vec4 ori = texture(SCREEN_TEXTURE, final_st);
    COLOR = vec4(red.r, ori.g, blue.b, 1.0);
}
```

**Animation from GDScript** (expand radius over time):

```gdscript
func trigger_shockwave(screen_pos: Vector2) -> void:
    var vp_size := get_viewport_rect().size
    var normalized := screen_pos / vp_size
    shockwave_material.set_shader_parameter("center", normalized)
    shockwave_material.set_shader_parameter("radius", 0.0)

    var tween := create_tween()
    tween.tween_property(shockwave_material, "shader_parameter/radius", 0.8, 0.4)\
        .set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD)
    tween.parallel().tween_property(shockwave_material, "shader_parameter/strength", 0.0, 0.4)\
        .from(0.08).set_ease(Tween.EASE_IN)
    tween.tween_callback(func(): shockwave_material.set_shader_parameter("radius", 0.0))
```

**Tested values**:

| Use case | `strength` | `width` | `aberration` | Duration |
|----------|-----------|---------|-------------|----------|
| Light impact (bullet hit wall) | 0.02 | 0.02 | 0.1 | 0.2s |
| Medium impact (explosion) | 0.05 | 0.035 | 0.3 | 0.35s |
| Heavy impact (boss slam) | 0.08 | 0.05 | 0.5 | 0.5s |
| Screen-wide (nuke/ultimate) | 0.1 | 0.08 | 0.7 | 0.7s |

### Water Distortion (2D)

Screen-space refraction using a noise texture as a displacement map.

```gdshader
shader_type canvas_item;

uniform sampler2D refraction_map;
uniform vec2 refraction_stretch = vec2(1.0, 1.0);
uniform float refraction_strength : hint_range(0.0, 0.1) = 0.02;
uniform vec4 water_tint : source_color = vec4(0.2, 0.6, 1.0, 0.1);
uniform float speed : hint_range(0.0, 1.0) = 0.1;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap;
varying vec2 globalposition;

void vertex() {
    globalposition = (MODEL_MATRIX * vec4(VERTEX, 0.0, 1.0)).xy;
}

void fragment() {
    vec2 uv = globalposition / 512.0;
    vec2 refraction_offset = texture(
        refraction_map,
        vec2(mod(uv.x * refraction_stretch.x + TIME * speed, 1.0),
             mod(uv.y * refraction_stretch.y + TIME * speed, 1.0))).xy;
    refraction_offset -= 0.5;
    vec4 refraction = texture(SCREEN_TEXTURE, SCREEN_UV - refraction_offset * refraction_strength);
    COLOR.rgb = mix(refraction.rgb, water_tint.rgb, water_tint.a);
    COLOR.a = 1.0;
}
```

**Tested water values**:

| Style | `refraction_strength` | `speed` | `water_tint` alpha | Feel |
|-------|----------------------|---------|-------------------|------|
| Calm lake | 0.008 | 0.03 | 0.08 | Barely perceptible ripple --- serene |
| River | 0.02 | 0.1 | 0.12 | Visible flow --- alive but calm |
| Turbulent | 0.05 | 0.25 | 0.2 | Aggressive distortion --- danger |
| Lava/acid | 0.04 | 0.06 | 0.35 | Thick, slow, opaque --- threatening |

**Piege**: The `512.0` divisor in `globalposition / 512.0` is hardcoded to tile the noise texture every 512px. For larger levels, increase this or the water pattern visibly repeats. For a 4096px-wide level, use `2048.0` or higher.

### Heat Distortion (2D Canvas Version)

For fire, forges, desert mirages. Localized UV offset using layered noise.

```gdshader
shader_type canvas_item;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap;
uniform sampler2D distortion_noise : repeat_enable;
uniform float distortion_strength : hint_range(0.0, 0.05) = 0.01;
uniform float speed : hint_range(0.0, 2.0) = 0.5;
uniform float noise_scale : hint_range(0.1, 10.0) = 3.0;

void fragment() {
    vec2 noise_uv = UV * noise_scale + vec2(0.0, TIME * speed);
    vec2 distortion = (texture(distortion_noise, noise_uv).rg - 0.5) * 2.0;
    distortion *= distortion_strength;

    // Fade at edges so distortion doesn't bleed outside the rect
    float edge_fade = smoothstep(0.0, 0.15, UV.y) * smoothstep(1.0, 0.85, UV.y)
                    * smoothstep(0.0, 0.15, UV.x) * smoothstep(1.0, 0.85, UV.x);
    distortion *= edge_fade;

    COLOR = texture(SCREEN_TEXTURE, SCREEN_UV + distortion);
    COLOR.a = edge_fade * 0.95; // Slight transparency at edges
}
```

**Setup**: Put a ColorRect where you want heat haze, apply this shader. The ColorRect size IS the distortion area. Use `color = Color(0, 0, 0, 0)` on the ColorRect to avoid white flash on first frame.

**Piege: Missing edge_fade = hard cutoff rectangle visible.** Without edge attenuation, the distortion abruptly stops at the rect boundary. Players will see a sharp rectangle floating in the air.

---

## 5. Chromatic Aberration

### Fullscreen Post-Process

```gdshader
shader_type canvas_item;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap;
uniform float amount : hint_range(0.0, 0.02) = 0.0;

void fragment() {
    float r = texture(SCREEN_TEXTURE, vec2(SCREEN_UV.x - amount, SCREEN_UV.y - amount)).r;
    float g = texture(SCREEN_TEXTURE, SCREEN_UV).g;
    float b = texture(SCREEN_TEXTURE, vec2(SCREEN_UV.x + amount, SCREEN_UV.y + amount)).b;
    COLOR = vec4(r, g, b, 1.0);
}
```

**Critical values** (the difference between "stylish" and "migraine"):

| Amount | Feel | Use case |
|--------|------|----------|
| 0.0005 | Barely visible. Subtle film grain vibe | Permanent ambient effect |
| 0.001--0.002 | Noticeable but comfortable | Damage flash, low health |
| 0.003--0.005 | Aggressive. Clearly "something is wrong" | Heavy hit, critical health |
| 0.008+ | Nauseating. Visible RGB split | Boss phase transition ONLY, max 0.5s |
| 0.01+ | Physically uncomfortable for most players | NEVER use as sustained effect |

**Rule**: Never sustain chromatic aberration above 0.003 for more than 1 second. Pulse it: spike to target value then ease back to 0.0 over 0.15--0.3s.

```gdscript
func chromatic_pulse(peak: float, duration: float) -> void:
    material.set_shader_parameter("amount", 0.0)
    var tween := create_tween()
    tween.tween_property(material, "shader_parameter/amount", peak, 0.02)
    tween.tween_property(material, "shader_parameter/amount", 0.0, duration)\
        .set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_EXPO)
```

### Radial Chromatic Aberration

Stronger at screen edges, zero at center. More realistic and less nauseating than uniform offset.

```gdshader
shader_type canvas_item;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap;
uniform float amount : hint_range(0.0, 0.05) = 0.0;

void fragment() {
    vec2 center_offset = SCREEN_UV - vec2(0.5);
    float dist = length(center_offset);
    vec2 dir = normalize(center_offset) * dist * amount;

    float r = texture(SCREEN_TEXTURE, SCREEN_UV - dir).r;
    float g = texture(SCREEN_TEXTURE, SCREEN_UV).g;
    float b = texture(SCREEN_TEXTURE, SCREEN_UV + dir).b;
    COLOR = vec4(r, g, b, 1.0);
}
```

**Advantage**: Players looking at the center of the screen (where they usually look) see zero aberration. The distortion is peripheral only, which is much more tolerable.

---

## 6. CRT / Retro Screen Effects

### All-In-One CRT

```gdshader
shader_type canvas_item;

uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_linear_mipmap, repeat_enable;

uniform float scanline_intensity : hint_range(0.0, 1.0) = 0.2;
uniform float barrel_distortion : hint_range(0.0, 0.5) = 0.15;
uniform float chromatic_aberration : hint_range(0.0, 0.01) = 0.005;
uniform float grain_intensity : hint_range(0.0, 1.0) = 0.1;
uniform float vignette_darkness : hint_range(0.0, 1.0) = 0.5;
uniform float vignette_outer : hint_range(0.1, 2.0) = 0.7;
uniform float vignette_inner : hint_range(0.0, 1.9) = 0.2;
uniform int pixel_resolution_x : hint_range(32, 640) = 320;
uniform int pixel_resolution_y : hint_range(24, 480) = 240;
uniform float warble_amount : hint_range(0.0, 0.01) = 0.002;
uniform float warble_speed : hint_range(0.0, 10.0) = 5.0;
```

**Tested presets**:

| Style | scanline | barrel | chroma | grain | warble | Resolution |
|-------|----------|--------|--------|-------|--------|------------|
| Subtle CRT (modern pixel art) | 0.08 | 0.05 | 0.001 | 0.03 | 0.0 | native |
| Arcade CRT (chunky) | 0.2 | 0.15 | 0.005 | 0.1 | 0.002 | 320x240 |
| VHS/analog horror | 0.15 | 0.08 | 0.008 | 0.2 | 0.006 | 320x240 |
| Broken TV (glitch) | 0.3 | 0.2 | 0.01 | 0.3 | 0.01 | 160x120 |

**Piege: CRT on pixel art with Nearest filtering = moire patterns.** The scanlines from the shader interfere with the pixel grid of the sprites. Either use Linear filtering on the CRT pass (post-process AFTER rendering), or match scanline frequency to your pixel scale exactly. If your pixels are 3 screen-pixels tall, set scanline period to 3.

**Piege: barrel_distortion > 0.3 pushes content off-screen.** Players lose corner UI elements. Keep it under 0.2 for gameplay, use 0.3+ only for menus/cutscenes.

---

## 7. Palette Swap at Runtime

### LUT-Based (Two-Texture Method)

```gdshader
shader_type canvas_item;

uniform sampler2D palette_reference : filter_nearest; // Original colors (1xN)
uniform sampler2D palette_target : filter_nearest;    // Replacement colors (1xN)
uniform float precision : hint_range(0.0, 0.1) = 0.01;

void fragment() {
    vec4 pixel = texture(TEXTURE, UV);
    int palette_size = textureSize(palette_reference, 0).x;

    for (int i = 0; i < palette_size; i++) {
        vec4 ref_color = texelFetch(palette_reference, ivec2(i, 0), 0);
        if (distance(pixel.rgb, ref_color.rgb) < precision) {
            vec4 target_color = texelFetch(palette_target, ivec2(i, 0), 0);
            COLOR = vec4(target_color.rgb, pixel.a);
            return;
        }
    }
    COLOR = pixel;
}
```

**Piege: `filter_nearest` is mandatory on palette textures.** Linear filtering interpolates between palette entries, producing colors that don't exist in either palette. The swap becomes a smear.

**Piege: Palette texture with > 32 colors = noticeable per-frame cost.** The loop runs for every pixel. Keep palette textures under 16--24 colors for real-time use. For larger palettes, use the index-based approach below.

### Index-Based (Pre-Indexed Sprites)

The sprite's red channel encodes the palette index (0--255). No color comparison needed --- direct lookup.

```gdshader
shader_type canvas_item;

uniform sampler2D palette : filter_nearest;
uniform float cycle_offset : hint_range(0, 256, 0.1) = 0.0;

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    float idx = floor(tex.r * 256.0);
    // Skip index 0 (transparent)
    if (idx > 0.0) {
        ivec2 pal_size = textureSize(palette, 0);
        int total = pal_size.x * pal_size.y;
        float final_idx = mod(idx + cycle_offset - 1.0, float(total - 1)) + 1.0;
        float u = mod(final_idx, float(pal_size.x)) / float(pal_size.x) + 0.5 / float(pal_size.x);
        float v = floor(final_idx / float(pal_size.x)) / float(pal_size.y) + 0.5 / float(pal_size.y);
        COLOR = texture(palette, vec2(u, v));
        COLOR.a = tex.a;
    } else {
        COLOR = vec4(0.0);
    }
}
```

**Use cases**:
- **Damage state**: Swap to red-shifted palette when hit
- **Poison/burn**: Swap to green/orange palette while status effect active
- **Team colors**: Same sprite, different palette per team
- **Day/night**: Shift entire tileset palette (cooler blues at night, warmer oranges at day)
- **Animated palette cycling**: Animate `cycle_offset` for waterfall/lava shimmer effects (classic SNES technique)

**Piege: The `+ 0.5 / float(pal_size.x)` is NOT optional.** Without center-of-pixel sampling, GPU interpolation samples from between two palette entries, producing garbage colors. This is the most common palette swap bug.

---

## 8. Screen Damage Flash

### Edge Vignette Flash

Stronger at screen edges, clear in center. The standard damage feedback for FPS and action games.

```gdshader
shader_type canvas_item;

uniform vec4 flash_color : source_color = vec4(1.0, 0.0, 0.0, 1.0);
uniform float intensity : hint_range(0.0, 1.0) = 0.0;
uniform float edge_power : hint_range(0.1, 4.0) = 2.5;
uniform float inner_ratio : hint_range(0.0, 1.0) = 0.75;
uniform float feather : hint_range(0.01, 1.0) = 0.4;

void fragment() {
    vec2 uv = UV * 2.0 - 1.0;
    float dist = max(abs(uv.x), abs(uv.y));
    float a = smoothstep(inner_ratio, inner_ratio + feather, dist);
    a = pow(a, edge_power);
    a *= intensity;
    COLOR = vec4(flash_color.rgb, a * flash_color.a);
}
```

**Setup**: ColorRect full-screen on a CanvasLayer above everything. Animate `intensity` via tween.

**Tested parameters**:

| Situation | `flash_color` | `edge_power` | `inner_ratio` | Duration |
|-----------|--------------|-------------|--------------|----------|
| Light hit | Red (1,0,0) | 3.0 | 0.8 | 0.15s ease-out |
| Heavy hit | Dark red (0.8,0,0) | 2.0 | 0.6 | 0.3s ease-out |
| Heal | Green (0.2,1,0.3) | 3.5 | 0.85 | 0.4s ease-out |
| Shield break | Blue (0.3,0.5,1) | 2.5 | 0.5 | 0.5s ease-out |
| Low health pulse | Red (1,0,0) | 2.0 | 0.7 | Looping sin wave |

---

## 9. Speed Lines / Focus Lines

```gdshader
shader_type canvas_item;

uniform sampler2D noise : repeat_enable;
uniform vec4 line_color : source_color;
uniform float line_count : hint_range(0.0, 2.0, 0.05) = 0.5;
uniform float line_density : hint_range(0.0, 1.0) = 0.5;
uniform float line_falloff : hint_range(0.0, 1.0) = 0.25;
uniform float mask_size : hint_range(0.0, 1.0) = 0.1;
uniform float mask_edge : hint_range(0.0, 1.0) = 0.5;
uniform float animation_speed : hint_range(1.0, 20.0) = 5.0;

vec2 polar_coordinates(vec2 uv, vec2 center, float zoom, float repeat_count) {
    vec2 dir = uv - center;
    float radius = length(dir) * 2.0;
    float angle = atan(dir.y, dir.x) / (PI * 2.0);
    return mod(vec2(radius * zoom, angle * repeat_count), 1.0);
}

void fragment() {
    vec2 polar_uv = polar_coordinates(UV, vec2(0.5), 0.01, line_count);
    polar_uv.x += TIME * 0.1; // Scroll along radius
    vec3 lines = texture(noise, polar_uv).rgb;

    float mask_value = length(UV - vec2(0.5));
    float mask = (mask_value - mask_size) / (mask_edge - mask_size);
    mask = clamp(mask, 0.0, 1.0);
    float result = 1.0 - (mask * line_density);
    result = smoothstep(result, result + line_falloff, lines.r);

    COLOR.rgb = line_color.rgb;
    COLOR.a = min(line_color.a, result);
}
```

**Use cases**: Boss intros (radial from boss position), dash abilities (directional), power-up activation (radial from player), ultimate/super charging (screen center).

**Setup**: ColorRect on CanvasLayer. Set `mask_size` to 0.3--0.5 to keep center clear (player needs to see). Animate `line_density` from 0 to target value over 0.1s to avoid pop-in.

---

## 10. Blur Vignette (Low Health / Focus)

```gdshader
shader_type canvas_item;

uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear_mipmap;
uniform float blur_amount : hint_range(0, 5) = 1.0;
uniform float blur_inner : hint_range(0, 1) = 0.6;
uniform float blur_outer : hint_range(0, 1) = 0.66;
uniform float blur_radius : hint_range(0, 1) = 0.2;

void fragment() {
    vec4 sharp = texture(screen_texture, SCREEN_UV);
    vec4 blurred = textureLod(screen_texture, SCREEN_UV, blur_amount);
    float dist = length(UV - vec2(0.5));
    float blur = smoothstep(blur_inner - blur_radius, blur_outer, dist);
    COLOR.rgb = mix(sharp.rgb, blurred.rgb, blur);
    COLOR.a = 1.0;
}
```

**Tested presets**:

| State | `blur_amount` | `blur_inner` | `blur_outer` | Feel |
|-------|-------------|-------------|-------------|------|
| Low health | 2.0 | 0.4 | 0.55 | Tunnel vision, panic |
| Aiming/focus | 1.5 | 0.15 | 0.25 | Center sharp, periphery soft --- sniper |
| Stun/dizzy | 3.0 | 0.0 | 0.3 | Everything blurry, center slightly less |
| Dreamlike | 1.0 | 0.5 | 0.7 | Gentle soft edges --- cutscenes |

---

## 11. Shader Combination Stacks

### Death Stack (Enemy Dies)

Layer these effects simultaneously:

1. **Hitstop**: `Engine.time_scale = 0.05` for 60ms
2. **White flash**: `flash_intensity` spike to 1.0, ramp to 0.0 over 100ms
3. **Dissolve**: Start at `dissolve_value = 0.0`, ease to 1.0 over 300ms (QUAD_IN)
4. **Particles**: Emit burst of 8--12 particles at frame 2 of dissolve
5. **Screenshake**: 4px diminishing, 150ms
6. **SFX**: Death sound at hitstop release (not at hit moment)

### Boss Phase Transition Stack

1. **Freeze frame**: `Engine.time_scale = 0.0` for 500ms (use `process_mode = ALWAYS` on timers)
2. **Screen flash**: Full white ColorRect, alpha 0 -> 0.8 over 100ms, hold 200ms, fade 400ms
3. **Chromatic aberration**: Spike to 0.008, ease to 0.0 over 600ms
4. **Shockwave**: Expand from boss center, radius 0 -> 0.8 over 400ms
5. **Screenshake**: 8px diminishing, 400ms
6. **Music shift**: Crossfade to phase 2 track during the freeze

### Damage Taken Stack

1. **Hitstop**: 40ms at `time_scale = 0.1`
2. **Sprite flash**: White additive, 50ms on, 80ms ease off
3. **Screen damage vignette**: Red edge, intensity 0.6, ease to 0.0 over 200ms
4. **Chromatic aberration**: Pulse to 0.002, ease out 150ms
5. **Screenshake**: 3px, directional (from hit source), 100ms
6. **Knockback**: Velocity impulse away from source

---

## 12. Common Shader Mistakes

### `discard` Kills Performance (Even If Never Reached)

If your shader contains `discard` anywhere in the fragment function, the GPU disables Early Depth Rejection for that material --- even if the `discard` line is inside an `if` that never triggers. This affects ALL pixels rendered by that material.

**Fix**: Use `COLOR.a = 0.0` instead of `discard` for transparency. Only use `discard` when you genuinely need to prevent depth writes.

### `floor(TIME * speed)` in Shaders = Pixel Flicker

Stepping time with `floor()` creates instant jumps between values, which in motion looks like flickering pixel blocks. Always use `sin(TIME * speed)`, `cos()`, or `smoothstep()` for animated effects.

### Shared ShaderMaterial Between Instances

All nodes referencing the same ShaderMaterial share uniform values. Setting `flash_intensity = 1.0` on one enemy flashes every enemy using that material. Fix: `material.resource_local_to_scene = true` in the resource, or `material = material.duplicate()` in `_ready()`.

### ColorRect Base Color = White Flash

A ColorRect used for screen-space shaders has `color = Color.WHITE` by default. If the shader has a compile error or takes a frame to load, the player sees a full white screen. Always set `color = Color(0, 0, 0, 0)` on post-process ColorRects.

### `smoothstep` Parameter Order

`smoothstep(edge0, edge1, x)` --- edge0 MUST be less than edge1. If `edge0 > edge1`, behavior is undefined (different GPUs give different results). This is a silent bug that only shows on certain hardware.

### Texture Filter Mismatch in Shaders

Shader `uniform sampler2D` uses the project's default filter unless specified. Pixel art shaders that sample `TEXTURE` work fine, but shaders sampling `SCREEN_TEXTURE` or custom textures may get linear filtering even in a nearest-filter project. Always specify: `uniform sampler2D my_tex : filter_nearest;` for pixel art.

### Pixel Art Outline Gets Cut at Sprite Edges

The outline shader samples pixels OUTSIDE the sprite's UV bounds (0--1). If the sprite texture fills the entire allocated region with no padding, those outside samples hit adjacent sprites in an atlas or wrap around. Fix: ensure 2--4px transparent padding around every sprite.

---

## 13. Screenshake Reference

### Types That Feel Right

| Type | Best for | Feel |
|------|----------|------|
| Random X/Y diminishing | Universal damage/impact | Standard action game hit |
| Directional (from source) | Getting hit, recoil | Communicates WHERE the hit came from |
| X Square Wave (3px fixed) | Rapid repeated hits | Consistent without overwhelming |
| Rotation sine (small angle) | Distant explosions | Comical, not aggressive |
| Scale out-then-in (zoom) | Power activation, boss spawn | Conveys force expanding outward |
| X sine diminishing | Denied action (locked door) | Head-shake "no" gesture |
| Perlin noise X/Y | Ambient rumble (earthquake) | Organic, non-repetitive |

### Magnitude Reference

| Event frequency | Magnitude (px) | Duration | Decay |
|----------------|----------------|----------|-------|
| Frequent (basic attack) | 2--3 | 0.08s | Linear |
| Regular (enemy death) | 4--6 | 0.15s | Exponential |
| Occasional (explosion) | 6--10 | 0.25s | Exponential |
| Rare (boss slam) | 10--15 | 0.4s | Slow exponential |
| Very rare (nuke/ultimate) | 15--25 | 0.6s | Slow start, fast end |

### Piege: Overlapping Screenshakes

Multiple simultaneous shakes compound. Two 5px shakes = 10px total if additive. Solution: take the MAX of active shakes instead of summing them, or use a priority system where heavy shakes suppress lighter ones.

### Pause + Shake Combo (Hitstop)

A 50--80ms `Engine.time_scale` freeze BEFORE the shake makes hits feel 3x more impactful for free. The freeze creates anticipation, the shake delivers the payoff.

| Freeze duration | Feel |
|----------------|------|
| 30--40ms | Subtle. "Something hit" |
| 50--80ms | Standard action game hit |
| 100--150ms | Heavy slam. Boss attacks |
| 200--300ms | Dramatic. Phase transitions, death blows |
| 300ms+ | Cinematic. Use with caution --- gameplay pauses |

**Piege: `Engine.time_scale` affects `get_tree().create_timer()`**. Use `create_timer(duration, true, false, true)` --- the 4th parameter `ignore_time_scale` must be `true`, otherwise your hitstop timer also freezes.
