Light and Shadow

Player character sheds light in darkness.

This is the fourth tutorial in a series that covers the creation of a simple true top-down 2D game with pixel graphics, using the Godot Engine, version 4. It follows Movable Objects and adds 2D lighting to it.

This tutorial uses Godot 4.3, the regular version, but you could also use the .NET version.

Darkness

At the moment our scene doesn't have any lighting. It is uniformly bright, which is not very interesting to look at. We're going to change that by darkening everything and then adding a light source. Besides a purely aesthetic choice, lighting can also be used as a gameplay element, for focusing attention and constraining vision.

Canvas Modulation

A 2D scene can be darkened by using the CanvasModulate node. Add such a node to map.tscn and adjusting its Color. This color is multiplied with everything in the scene, darkening it. If it's set to black then nothing would be visible, but we pick a dark bluish color that still allows us to see the unlit scene, like 24293b.

bright dark
Bright and dark scene.

The only thing not affected by the modulation is the player's particle system, because it uses an unlit shader. Let's also make the player's sprite unaffected by the darkness, because it's supposed to be a glowing sphere. Give the Node2D in player_character.tscn a new CanvasItemMaterial and set its Light Mode to Unshaded.

Unlit material.

2D Light

Go back to map.tscn and add a PointLight2D node to it. Set its Texture to a new GradientTexture2D with a radial gradient, just like the player's sprite but going from white to black and with Width and Height set to 192. That gives our light a linear intensity falloff with a radius of six tiles. The default gradient goes the other way but can easily be reversed via its Reverse/Mirror Gradient toolbar button. I put the light in the same position as the player so it's easy to see where it is located.

Light in the darkness.

Next, enable shadows for the light by activating its PointLight2D › Shadow › Enabled option. We keep its Filter as None (Fast) because otherwise we would get smooth shadows that won't work with our low-resolution pixel style.

Shadows settings.

Although our light should now cast shadows none appear, because we haven't defined anything that casts shadows yet.

Tile Shadows

For tiles to cast shadows, we have to first add an occlusion layer to our tile map node. Go to its TileMapLayer › Tile Set › Rendering › Occlusion Layers settings and use Add Element to create a single light mask layer.

Occlusion layer with mask set to 1.

This layer works just like a physics layer, but is used for shadow casting instead of collisions. So we have to go to the TileSet editor and add occlusion polygons to the appropriate tiles. Select the three wall tiles and give them a solid square polygon, matching their collision polygon, under Rendering › Occlusion Layer 0.

Wall tile occlusion settings.

Now the wall tiles block light, casting shadows. This means that walls themselves will also always be in their own shadow, which is fine.

Tile Shadows.

In the editor's scene window the shadows appear at a high resolution, but when running the game they appear at the same resolution as everything else. The shadows are mostly sharp, with a bit of anti-aliasing.

Low-resolution shadows.

Movable Object Shadows

To let our movable objects also cast shadows, add a LightOccluder2D node to movable_object.tscn. Give its Occluder property a new OccluderPolygon2D. Then draw a quad matching the sprite outline in the scene, or add the vertices manually in the inspector. You could also instead select the Sprite2D node in the scene and then pick the Create LightOccluder Sibling from the Sprite2D toolbar menu.

Set the occluder's Cull Mode to CounterClockWise.

Light occluder settings.

The objects now also cast shadows. They would've also self-occluded, being shadowed all the time, but we avoided that with a counterclockwise cull mode. That controls the direction from which the polygon casts shadows. Counterclockwise means that the shadows are only cast outward, not inward.

counterclockwise default
Object shadows, counterclockwise and default culling mode.

Dual Lighting

Although everything works reasonably well at this point, it doesn't look good when movable objects shadow each other.

Object-object occlusion.

Let's declare that the light's elevation is such that it would shine over the movable objects. This means that the objects would not shadow each other, only the ground. That would look a lot better. To do this we'll have to separate the shadows of the objects and the tiles, which is possible by using different light masks.

Change the object sprite's CanvasItem › Visibility › Light Mask from 1 to 2. This will make all the movable objects dark, because they're no longer affected by our light. However, they still cast shadows for it because we haven't changed the occluder's mask.

Light mask set to 2.

Go back to map.tscn, duplicate the light, and set the new light's Item Cull Mask under both Range and Shadow to 2.

Light 2 settings.

This new light only affects things with their mask set to 2, which are the movable objects. Besides that it also only receives shadows from layer 2, which is currently nothing. Disable the first light to see the result.

Only objects lit by second light.

Using this light, movable objects don't shadow each other. However, they should still be shadowed by the wall tiles. To fix that, also enable light mask 2 for the tile map's occlusion layer, making it cast shadows for both lights.

Occluding both 1 and 2.

Now the movable objects are shadowed like we want.

Tile-object occlusion.

Finally, enable both lights so the tile map is also correctly lit.

Both lights produce the desired lighting.

Note that because movable objects no longer shadow themselves we can reset their cull mode setting back to the default.

Isn't using two lights bad for performance?

We're indeed using two shadow-casting lights to do the work of a single visible light source. However, we have such a simple low-resolution scene that it isn't a problem. But we shouldn't fill the scene with a lot of such lights.

Lively Light

We have a proper light, but it doesn't look very nice. We can improve its ambiance a lot by animating it a little. To do this we'll use a scene to design our own custom light.

Scene

Create a new lively_light.tscn scene with a Node2D root named LivelyLight. Copy both lights to this scene and reset their position to zero. Name them LightA and LightB. Then add a new GDScript to the root node, saving it as lively_light.gd. Give it exported variables for both lights and connect them via the inspector.

extends Node2D

@export var light_a: PointLight2D
@export var light_b: PointLight2D

Replace the two lights in map.tscn with a single instance of this lively light.

Jittering Position

We make our light appear lively by slightly jittering its position. This way it behaves more like fire than an LED light. Add configuration float variables for a jitter offset and jitter speed, set to 0.25 pixels and 6 jitters per second by default.

extends Node2D

## Maximum jitter offset per dimension, in pixels.
@export var jitter_offset := 0.25
## How fast the light changes, in jitters per second.
@export var jitter_speed := 6.0

@export var light_a: PointLight2D
@export var light_b: PointLight2D

We could use a timer to control the jittering, but let's code it ourselves this time. We do that by adding a float progress variable, which we increase by the time delta multiplied by the jitter speed in the _process() function. Then if the progress reached or exceeded 1 we subtract 1 from it and call a new jitter() function.

var progress := 0.0


func _process(delta: float) -> void:
	progress += delta * jitter_speed
	if progress >= 1.0:
		progress -= 1.0
		jitter()


func jitter() -> void:
	pass

In jitter() we create a random Vector2 position variable using randf_range and the configured jitter offset, so the position can end up anywhere in a square twice the jitter offset wide, centered on zero. Use that to set the position of both lights.

func jitter() -> void:
	var p := Vector2(
			randf_range(-jitter_offset, jitter_offset),
			randf_range(-jitter_offset, jitter_offset)
	)
	light_a.position = p
	light_b.position = p
Jittery light position.

Jittering Energy

Let's also jitter the light's energy, so its intensity fluctuates a little. Add a configuration variable for the minimum energy that can be reached by jittering, with a 0.0–1.0 range and a default of 0.95.

@export var jitter_speed := 6.0
## Minimum light energy level caused by jittering.
@export_range(0.0, 1.0) var jitter_min_energy := 0.95

Use it to get a random energy value from the minimum up to 1 and set the energy of both lights to that value.

	light_b.position = p

	var e := randf_range(jitter_min_energy, 1.0)
	light_a.energy = e
	light_b.energy = e
Jittery light position and energy.

Player Light

We're done with our light. As our player character is supposed to be a glowing sphere we'll make it the single light source of our scene. Remove the lively light from map.tscn and add one to player_character.tscn instead, with its position set to zero. Now the light moves with the player.

Player light.

Phantom Shadows

One imperfection of our lighting is that we can get phantom shadows cast in front of movable objects when they aren't positioned on exact integer coordinates. When that happens the regular rendering and shadow map can disagree where an object is, causing a relative shadow displacement of a single pixel. This happens when a position's fractional part is close to 0.5. There is a 2D snapping option in the project settings, but it doesn't fix this issue and causes other problems, so that's no help.

Phantom shadows.

We could solve this by rounding the positions ourselves, but we cannot round the position every physics process step because that would mess up the object's motion. Fortunately this problem is only really apparent for stationary objects that the player can stare at, it's not very obvious when objects are moving. So we can solve it adequately by rounding the position only when objects become stationary.

In the _physics_process() function of movable_object.gd we already check whether the object is moving fast enough before updating its position and velocity. So all we have to do is add an else block to that condition and round the position there.

func _physics_process(delta: float) -> void:
	if velocity.length_squared() > 1.0:
		velocity *= 1.0 - drag * delta
		if move_and_slide():
			resolve_collisions()
	else:
		position = round(position)

While we're at it, let's also get rid of the remaining velocity that we ignore, setting it to zero.

		position = round(position)
		velocity = Vector2.ZERO

Our game is starting to look interesting, but there's still a lot more that we can add to it. We will do so in the future.