Light and Shadow
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.
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.
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.
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.
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.
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.
Now the wall tiles block light, casting shadows. This means that walls themselves will also always be in their own shadow, which is fine.
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.
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.
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.
Dual Lighting
Although everything works reasonably well at this point, it doesn't look good when movable objects shadow each other.
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.
Go back to map.tscn
, duplicate the light, and set the new light's Item Cull Mask under both Range and Shadow to 2.
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.
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.
Now the movable objects are shadowed like we want.
Finally, enable both lights so the tile map is also correctly lit.
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
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
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.
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.
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.