2D lighting
The full 2D lighting stack: the CanvasModulate/PointLight2D/occluder triad, emissive exemptions, real glow, SDF.
The complete 2D lighting stack: the darkness/light/shadow triad, what must be exempted from
it, normal-mapped sprites, glow, and the SDF layer. Every recipe driven live
with the lighting commands (screenshot-verified light pool, emissive, and CanvasTexture
shading). Day/night color cycling lives in topdown-2d.md; this file is the machinery.
The triad: darkness → lights → shadows#
1. CanvasModulate is the darkness. It multiplies every canvas item’s color by its own
(channels run 0 to 1, so the product only ever darkens; pure white is identity, pure
black erases everything). There is one per canvas, and lighting canvas-modulate --color '#26264d' creates or updates it. Tint it for mood as well as night: a dusk orange or cave blue is the
same node. What escapes it: children of a CanvasLayer (HUD), and nothing else.
Parallax2D in particular IS affected. To keep a parallax background out of the
darkness (the Tropical Freeze look), wrap it in a CanvasLayer with a negative layer.
2. PointLight2D burns holes in it. A point light renders nothing without a texture, so
lighting add-2d --type PointLight2D --range 256 --color '#ffd9a0' --shadows true generates a
radial-gradient texture and the light shows immediately; swap in an authored texture for shaped
lights (window slats, flashlight cones). --type DirectionalLight2D is the 2D sun/moon. It shades
flat and directional, needs no texture, and wants a low energy as fill.
Use the cull masks deliberately: range_item_cull_mask picks which items a light touches
(via each item’s light_mask), so background lights never bleed onto gameplay sprites and vice
versa. That separation buys legibility, since it helps players tell interactive from
decorative. Set masks with --properties '{"range_item_cull_mask":2}' / node.set on the sprites’ light_mask.
3. LightOccluder2D casts the shadows. Darkness + light still gives no shadows until
occluders exist: lighting occluder-2d --parent-path Wall --size 'Vector2(256,256)' (or
--polygon for a traced shape). Discipline, all from shipped-game practice:
- Shadows are the expensive part. Not every light needs them (
--shadowsis per light), andshadow_item_cull_masklimits which occluders a light even tests. Keep polygons coarse, because every vertex is math. - Put occluders in the prefab scenes of walls/props so levels compose them for free; for a small level, one hand-drawn occluder over the whole layout beats dozens of pieces.
- TileSet occluders seam: per-tile occluders tend to leave hairline gaps between tiles that break shadow rendering. The pragmatic fix is exactly the previous point. Draw one occluder polygon over the tile geometry at the level scene.
Exempt what emits: unshaded + additive#
Fire, sparks, lasers, muzzle flashes, and bright particles must not be darkened, so plan for it.
lighting emissive-2d --node-path Flame --mode unshaded --additive gives the node a
CanvasItemMaterial with light_mode = UNSHADED (ignores CanvasModulate and all lights) and
additive blending (it brightens what is behind it, which is right for energy and heat). --mode light_only is the inverse: visible only where light hits. That is the tool for glow-decal layers
a flashlight reveals (pair with a dedicated light layer so only chosen sources trigger it).
Normal-mapped sprites: lights get direction#
Flat sprites light uniformly; a normal map makes a 2D light rake across them.
lighting normal-map-2d --node-path Wall --normal res://art/wall_n.png [--diffuse ...] [--specular ... --shininess 0.6] wraps the sprite’s texture in a CanvasTexture
(diffuse + normal + specular in one resource, and any Texture2D slot accepts it). Author normal
maps in your paint tool or generate from height; feeding a non-normal texture shades wrong in
exactly the way you’d expect (verified live). Works on TileSets too via the atlas’s texture.
Glow: real bloom for 2D#
The additive trick brightens, but real glow needs HDR: lighting glow-2d --threshold 0.9 --intensity 1.2 flips rendering/viewport/hdr_2d on (persisted to project.godot; needs an
editor/game restart to take effect) and adds a WorldEnvironment with additive glow. After
that, pixels pushed past the threshold (additive overlaps, color values > 1 on lights, HDR
sprites) bloom for free. Without hdr_2d, 2D renders in LDR and the glow pass has nothing to
find. That is the gotcha that makes people think 2D glow is broken.
The SDF layer (cheap fancy effects)#
Every LightOccluder2D also renders into a global 2D signed distance field
(sdf_collision on the occluder, on by default; our occluder-2d exposes --sdf-collision).
Any canvas_item shader can read it: texture_sdf(uv) returns the distance to the nearest
occluder, which is how you get soft contact shadows, cheap ambient occlusion against walls,
GPU particles colliding with level geometry (GPUParticles2D collision mode SDF), and
heat-haze that hugs surfaces, all without per-pixel raycasts. Tune the field’s range in
project settings (rendering/2d/sdf/*) if effects clip.
Composition rules (from shipped 2D games)#
- Decide per element which of the three regimes it lives in: lit (default), unshaded (emitters, UI-ish overlays), light-only (revealed layers). Mixed regimes on one screen are the look.
- Lights direct attention: warm inviting pools along the intended path, red for danger, one
animated light (flicker via a looping
AnimationPlayeronenergy) reads as alive. - Use real-time shadows only where they answer a gameplay or mood question; a busy scene with everything shadowed reads worse.
- Background and foreground get separate lights via cull masks; the player should never lose their character to a background light.
Verify like an agent#
editor screenshot --save-path <png> after composing (switch the main screen with
editor run-script --code 'EditorInterface.set_main_screen_editor("2D")' first if the 3D tab
is active), and check numerically with node get. A light that “does nothing” is usually a
missing texture, a mask mismatch, or hdr_2d still off.