Quick answer: Snap shared vertices between adjacent LightOccluder2D nodes to identical coordinates and close each polygon. Increase Light2D.shadow_filter_smooth to mask sub-pixel residue.
A 2D dungeon scene lit by a single Light2D looks great except in one corner: a thin slice of light is making it into a room that should be in total darkness. The occluder walls are sitting right next to each other but a hairline leak betrays the seam.
The Geometry Problem
Light2D builds shadows by extruding each LightOccluder2D polygon away from the light source. If two occluders meet at a corner but their meeting vertices are off by even 0.001 pixels, the extrusion produces two non-overlapping shadow volumes with a gap between them. Light passes through that gap.
Causes of misaligned vertices:
- Drag-and-drop polygon placement in the editor without grid snapping.
- Two TileSet tiles whose occlusion shapes don’t share endpoints.
- Programmatically-generated occluders with floating-point accumulation errors.
- Imported shapes from external tools using slightly different precision.
Fix 1: Snap Vertices
Enable Use Snap in the 2D editor toolbar and set step size to 1 px (or your tile pixel size). Open each LightOccluder2D and re-edit its polygon — vertices will snap to integer coordinates. For TileSet occlusion shapes, ensure the “Snap Options” in the TileSet editor are enabled and the polygon corners sit on the tile boundaries.
For programmatically built occluders, quantize coordinates:
func snap_poly(poly: PackedVector2Array, grid: float) -> PackedVector2Array:
var result = PackedVector2Array()
for v in poly:
result.append(Vector2(round(v.x / grid) * grid, round(v.y / grid) * grid))
return result
Fix 2: Close the Polygon
OccluderPolygon2D has a closed property. When true (default), the polygon is treated as a solid loop. When false, it’s a polyline — only the line segments cast shadows, not the interior. A polyline occluder will cast correct shadows only from the line itself, leaving rays parallel to or behind the line unhandled. For room walls, always use closed = true.
Fix 3: Increase shadow_filter_smooth
Once the geometry is correct, residue from precision limits can still show as a thin bright line. Increase the Light2D’s shadow filter smoothness:
@onready var light: Light2D = $TorchLight
func _ready():
light.shadow_filter = Light2D.SHADOW_FILTER_PCF13
light.shadow_filter_smooth = 2.0 # pixels
PCF13 samples 13 points around each shadow edge pixel; the smooth value scales the sampling kernel. Higher values blur the shadow edge more — great for masking sub-pixel leaks, less great for sharp tactical shadows. Tune for your art style.
Fix 4: Bias and Extension
Each OccluderPolygon2D can be inset or outset via vertex editing. If shadows still leak after snapping, expand the polygon outward by 1 pixel along each edge so neighboring occluders overlap rather than merely touching:
# Inflate a polygon outward by ‘offset’ pixels
func inflate(poly: PackedVector2Array, offset: float) -> PackedVector2Array:
var n = poly.size()
var result = PackedVector2Array()
for i in n:
var prev = poly[(i - 1 + n) % n]
var curr = poly[i]
var nxt = poly[(i + 1) % n]
var normal = ((curr - prev).orthogonal().normalized() + (nxt - curr).orthogonal().normalized()).normalized()
result.append(curr + normal * offset)
return result
Overlapping occluders combine cleanly into a single shadow volume with no gap.
Verifying
Use the Editor’s 2D Debug → Visible Collision Shapes to inspect occluder geometry. Run the game and move the light source around the suspect corner — if leaks reappear at specific angles, the geometry mismatch is still there. After the fixes, the shadow stays solid at all light angles.
“Light leaks are a geometry problem dressed up as a shader problem. Fix the polygons first; the filter is icing.”
When building dungeon walls procedurally, snap to integer pixels — floating point accumulates and you’ll see it three rooms down.