# Godot 4 — Lights & Shadows

> **Purpose**: Lighting as mood tool — CanvasModulate darkness floors, gradient PointLight2D technique, posterization shaders, normal map workflow, and how shipped games use darkness to make players feel. The marinade — not lighting API docs.

---

## 1. Darkness as a Design Tool

Lighting in 2D isn't about realism. It's about **what the player sees first** and **what they feel** while seeing it.

The visual progression every lighting setup follows:

1. **Unshaded** — flat, full brightness. This is Godot's default. Fine for bright overworlds, terrible for mood
2. **CanvasModulate darkness floor** — the scene goes dark, lights become meaningful. Now you control attention
3. **Lights + Shadows + Normal maps** — full volume, entities anchored in the world. The scene breathes

### Shipped Game Darkness Levels

These are the CanvasModulate `Color()` values that define how dark "dark" actually is in published games:

| Game | Darkness Style | Approximate CanvasModulate | Feeling |
|------|---------------|---------------------------|---------|
| **Hollow Knight** (Deepnest) | Near-black, bioluminescent accents | `Color(0.04, 0.04, 0.06)` | Suffocating, lonely, isolated. Enemies brighter than environment = you see them before you see the world |
| **Hollow Knight** (City of Tears) | Blue-gray mist, moderate visibility | `Color(0.20, 0.22, 0.35)` | Melancholic, vast, rain-washed. Cold blue dominates |
| **Dead Cells** (Prison) | Dark but readable, high-contrast threats | `Color(0.12, 0.10, 0.15)` | Confined, tense but fair. Saturated threats pop against dark base |
| **Celeste** (Mirror Temple) | Deep purple-cyan, introspective | `Color(0.08, 0.06, 0.14)` | Confrontation with self. Purple = unnatural, internal |
| **Ori** (Blind Forest dark areas) | Warm darkness, golden light punctuation | `Color(0.06, 0.05, 0.04)` | Dangerous but beautiful. Warm dark = organic, alive |
| **Shovel Knight** (Specter Knight levels) | Blue-black, ghostly | `Color(0.05, 0.06, 0.12)` | Ethereal, otherworldly. Blue dark = cold death |

**The rule**: never go below `Color(0.02, 0.02, 0.02)` or the halo edge of your lights becomes visible against true black. The "floor" needs to have just enough color to mask the gradient boundary.

### CanvasModulate Interaction with Lights

CanvasModulate is **multiplicative**, not additive. `Color(0.5, 0.5, 1.0)` doesn't add blue — it halves red and green. Dark colors become near-black fast.

Lights are applied AFTER CanvasModulate. A dark CanvasModulate makes lights look brighter by contrast. Design your light Energy values **with your target darkness in mind** — a light that looks good on white CanvasModulate will blow out on dark CanvasModulate.

Only one CanvasModulate per scene. Multiple nodes = only the last one processed wins. Use a single one and tween its color.

---

## 2. Gradient PointLight2D Technique

The texture **is** the light. It's not a physical simulation — it's an image displayed in additive blending. This means the texture defines everything: shape, falloff, character.

### The Gradient That Doesn't Suck

Default circle textures produce harsh, uniform halos. The technique used in polished games:

1. Create a `GradientTexture2D` set to **Radial** fill
2. Gradient: **RGB opaque** (white center → black edges), never transparent-to-transparent
3. **CUBIC** interpolation — linear produces visible banding on large lights
4. **Max 3 stops** — center white, mid-gray falloff, black edge. More stops = more chances for banding
5. **512x512 minimum** resolution — smaller textures produce pixelated halos at large Texture Scale

The gradient controls the light's personality:
- **Tight falloff** (stop 2 close to center): torch, candle — intimate, focused
- **Wide falloff** (stop 2 near edge): ambient glow, moonlight pooling — diffuse, atmospheric
- **Asymmetric stops**: uneven falloff for stylized, painterly lights

### Intensity Hierarchy

Not all lights should have the same Energy. Match intensity to the fictional source:

| Source | Energy | Texture Scale | Feeling |
|--------|--------|---------------|---------|
| Candle / firefly | 0.3-0.5 | 0.5-1.0 | Intimate, fragile |
| Torch / campfire | 0.7-1.0 | 1.5-2.5 | Warm, safe radius |
| Lantern / lamp | 0.8-1.2 | 2.0-3.0 | Reliable, functional |
| Bonfire / magical source | 1.2-1.8 | 3.0-5.0 | Dramatic, focal point |
| Explosion / power-up | 2.0+ | 4.0-8.0 | Momentary, blinding |

Uniform intensity across different sources flattens the scene and kills depth perception. The variance creates believable hierarchy.

### Light as Attention Guide

Each light should have a purpose. Don't spam lights — unlit areas create contrast that naturally draws the eye toward lit zones.

- Illuminate **points of interaction**: doors, items, NPCs, puzzle elements
- Leave **negative space** dark: corridors between rooms, decorative areas
- The player follows the light. If you light everything, you guide nothing

---

## 3. Posterization Shader (Palette-Locked Lighting)

### The Problem

Godot's 2D lights blend additively — they add white/color on top of existing pixels. This breaks a strict color palette because resulting colors are no longer part of the intended palette. Smooth gradients appear where you wanted hard posterized steps.

### The Solution: void light() Override

Force the light pass through the same posterization logic as the base sprite rendering:

```gdshader
shader_type canvas_item;

uniform float steps : hint_range(2.0, 16.0) = 4.0;
uniform float hue_shift : hint_range(0.0, 1.0) = 0.0;

vec3 hsv2rgb(vec3 c) {
    vec3 p = abs(fract(vec3(c.x) + vec3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0);
    return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
}

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    float brightness = dot(tex.rgb, vec3(0.299, 0.587, 0.114));
    brightness = floor(brightness * steps) / steps;
    COLOR = vec4(hsv2rgb(vec3(hue_shift, 0.6, brightness)), tex.a);
}

void light() {
    float lit_brightness = dot(LIGHT_COLOR.rgb * LIGHT_ENERGY, vec3(0.299, 0.587, 0.114));
    float base_brightness = dot(COLOR.rgb, vec3(0.299, 0.587, 0.114));

    float total = clamp(base_brightness + lit_brightness, 0.0, 1.0);
    total = floor(total * steps) / steps;

    LIGHT = vec4(hsv2rgb(vec3(hue_shift, 0.6, total)) - COLOR.rgb, COLOR.a);
}
```

`void light()` runs per-light, per-pixel. By re-running the palette math, every lit pixel maps to a valid palette color — no smooth intermediate values leak through.

**Production note**: the hue_shift approach is a starting point. For full artistic control, replace the HSV math with a `GradientTexture1D` sampled by brightness. Swap gradient textures to change entire biome moods instantly.

### Noise-Based Dithering (Anti-Banding)

Posterization creates flat, uniform color bands. Large surfaces look static and lifeless.

Before the posterize step, offset brightness with a noise texture:

```gdshader
uniform sampler2D noise_texture;
uniform float noise_strength : hint_range(0.0, 0.2) = 0.05;

// In fragment():
float noise = texture(noise_texture, UV).r;
brightness += (noise - 0.5) * noise_strength;
// Then posterize...
```

Surfaces that were flat blocks now have subtle grain — like wind across them or a hand-painted quality. Especially effective on large backgrounds (floors, walls, skies).

### Pixel Art Variant

The engine computes lighting at Viewport pixel resolution, not sprite texel resolution. `Nearest` filtering on sprites does NOT make lighting pixelated.

Snap light sampling to a pixel grid:

```gdshader
uniform float pixel_size = 4.0;

void fragment() {
    LIGHT_VERTEX.xy = floor(LIGHT_VERTEX.xy / pixel_size) * pixel_size;
    SHADOW_VERTEX = floor(SHADOW_VERTEX / pixel_size) * pixel_size;
    COLOR = texture(TEXTURE, UV);
}
```

---

## 4. Normal Maps — Volume from Flat Sprites

Normal maps make flat 2D sprites react to light as if they had 3D surface detail. A pixel on the left edge darkens when light comes from the right. Without normal maps, a PointLight2D uniformly brightens everything — with them, highlights and shadows shift dynamically within the sprite.

### Hand-Painting Workflow (Normal Sphere Technique)

Auto-generated normal maps produce poor results on stylized pixel art. Hand-painting gives better control:

1. **Get a normal sphere reference** — a sphere rendered with correct normal map colors. Each point shows the color for that surface angle
2. **Flat fill first** — all forward-facing pixels get `(128, 128, 255)` (neutral "pointing at camera")
3. **Color-pick for edges** — right edge of a rounded object = pick from right side of sphere (more red, less blue). Top bevel = pick from top (more green)
4. **Zone by zone** — major forms first (body volumes, weapon shapes), details later

For pixel art: avoid smooth gradients between normal colors. Use flat zones with hard transitions matching the sprite aesthetic.

### Setup via CanvasTexture

CanvasTexture ties diffuse texture + normal map into a single assignable resource, per-sprite:

1. On the sprite, create a **New CanvasTexture** instead of a regular Texture2D
2. `Diffuse > Texture` = the sprite's color texture
3. `Normal Map > Texture` = the hand-painted normal map
4. (Optional) `Specular > Texture` = reflective highlights

After adding normal maps, lights appear weaker. **Always increase `Height`** on your light nodes — the default near-zero value makes normal effects nearly invisible. Bump Energy slightly to restore brightness.

---

## 5. Shadows — Anchoring Entities in the World

### Dynamic Shadows on Moving Entities

Don't limit occluders to static geometry. Adding `LightOccluder2D` as a child of moving entities (player, enemies, NPCs) makes them cast shadows that follow movement. This visually **anchors** entities in the lit environment — without it, characters float on top of the scene, disconnected from the lighting.

Setup: add a `LightOccluder2D` child to the entity's scene root, draw a simplified polygon approximating the silhouette (doesn't need to be pixel-perfect), and it casts shadows from every light with Shadow enabled.

### Shadow Filter Choice

| Mode | Look | Performance | Use for |
|------|------|-------------|---------|
| None | Hard/blocky edges | Fastest | Pixel art aesthetic, deliberate chunky look |
| PCF5 | Moderate softness | Medium | General purpose, most games |
| PCF13 | Smoothest | Expensive | Only a few key lights. Kills mobile perf at scale |

Shadow Color matters: default full black is fine for most cases. Dark blue shadows for moonlight scenes, dark purple for magic/corruption — the shadow color sets the mood of the darkness itself.

---

## 6. Pièges visuels

1. **`floor(TIME * speed)` dans un shader = pixels carrés qui flickent** — toujours `sin(TIME)` / `cos(TIME)` pour des animations smooth. Le `floor` quantize le temps et produit des sauts visuels

2. **Shader = surface, jamais lumière** — mixer un shader glow + PointLight2D sur le même objet = double-bubble effect. Le shader illumine la surface, le light illumine la zone. Choisir l'un ou l'autre

3. **Mobile renderer = banding visible** — les gradients subtils de lumière montrent du banding sur mobile renderer. Forward+ ou PNG dithered pour compenser

4. **VFX toujours démarrer à 20% d'intensité** — monter ensuite. Commencer à 100% et réduire = "pourquoi l'écran est noir / tout blanc". L'oeil s'adapte mieux aux ajouts qu'aux retraits

5. **Shadow direction = node position, pas texture offset** — bouger l'Offset shift le glow visuel mais ne change PAS la direction des ombres. Les ombres sont toujours castées depuis la position du node

6. **Directional shadows = infiniment longues** — impossible à changer sans shader SDF custom. Si tu veux des ombres de longueur finie depuis une source directionnelle, désactiver les shadows sur le DirectionalLight2D et utiliser le signed distance field

7. **Zoom et Max Distance** — le Max Distance du DirectionalLight2D ne tient pas compte du zoom Camera2D. A haut zoom, les ombres peuvent disparaitre plus tot que prévu

8. **Background non-lit** — la couleur de fond ne reçoit pas de lumière. Pour un background lit, il faut un Sprite2D (ou visual node). `Region` + `Texture > Repeat = Enabled` pour tiler

9. **Occluder sans polygon = invisible** — un LightOccluder2D sans OccluderPolygon2D assigné ne fait rien. Silencieux

10. **Light performance = pixels couverts** — Texture Scale augmente directement le cout GPU. Grosses lumières = plus de pixels = plus de travail. PCF13 sur 10+ lumières = framedrops garantis

---

## Stack combiné

L'ordre dans lequel les techniques se combinent pour une scene complete :

```
1. CanvasModulate              → Darkness floor. Choisir la couleur d'ambiance (cf. table shipped games)
2. Posterization shader        → Sprites verrouillés sur une palette. Steps 4-8 selon le style
3. Noise dithering             → Casse les bandes plates, ajoute du grain organique
4. PointLight2D stratégiques   → Gradient radial CUBIC, 512x512, intensité variable par source
5. void light() override       → Force les pixels éclairés dans la palette. Pas de gradients parasites
6. Normal maps (CanvasTexture) → Volume dynamique sur les sprites. Height > 0 obligatoire
7. LightOccluder2D             → Ombres sur static ET moving entities. Ancrage dans le monde
8. Shadow Color                → Teinter les ombres pour le mood (bleu nuit, violet magic, etc.)
```

Le résultat : une scene 2D avec discipline de palette, éclairage volumétrique dynamique, et profondeur par les ombres — tout en gardant une esthétique stylisée.
