Understanding Light2D Masks in Godot
2D lighting in Godot is easy to get the basics working, but there's very little in the way of actual examples kicking around. This article goes through a detailed example of 2D top-down lighting, showing what all of the light masks actually do, and how to accomplish some moderately advanced lighting techniques.
While this is written for Godot 3.4, I've tested in the godot 4 alpha and most of the content will still apply in future versions!
The setup
I'm going to be working with a simple top-down test scene. It contains:
- a Player sprite (with normalmap)
- with a child LightOccluder2D
- 2 Blocks (also with normalmaps)
- each with a child LightOccluder2D
- a simple sprite as the background/floor
- a CanvasModulate, to darken things up
- A main Light2D
- with another Light2D nested under it (you'll see why)
Starting in the Dark
First, set up the CanvasModulate to a middlish grey - #878787.
CanvasModulate works by multiplying everything in that canvas layer by the modulate value. We don't have photons or raycasting or phong models or whatever, so we make things look darker by "squishing" all of the colours down towards black. This gives us some room to bring things back up towards white and make them look "lit" when the Light2Ds add their values to the scene.
Here's what things look like by default- pretty dim and boring.
Turning on the Lights
Enabling the Light2D starts to make things look interesting, thought not very realistic. You can see the normalmaps working as expected, though a real game would have spent more than 5 minutes on them. Nonetheless, we have some depth, and the blocks respond to the light as if they were 3D.
Adding the Occluders
Okay, the obvious thing is to add occluders and start casting shadows! But the most obvious way to do that suddenly breaks our nice normal maps, and the player is somehow casting shadows on his own head! The blocks are actually self-shadowing too - the occluders are "over top" of the normalmap, so the normal map is fully shaded. What can we do?
Here's the editor-view, showing the occluders:
One thing you can do to make these less annoying in the editor is to set them to "Show Behind Parent", and set their modulate to be transparent. This doesn't impact the running game, but makes it easier to see what's going on while editing.
Masks masks masks
Searching around will quickly lead to cryptic posts talking about setting the masks "correctly". But there's no less than 4 different kinds of masks involved, really slim documentation about each, and all of them talk about layers. I'll come back to the various masks in a bit - first we'll give the layers some names to make the rest easier.
By default, Godot puts everything in light mask layer 1. We're going to use a bunch of different layers, one for each kind of object we're dealing with.
In the global project settings, find the 2d Render
section and give layers 1-7 the following names. The actual names don't matter, they're just going to help us figure out which ones are which while we set up the lights properly.
I found some references to layer 1 being "special" in godot internals, but I'm pretty sure that got fixed a while back. Either way, I'm not going to use it for this demo - the actual layers we're using are arbitrary, we just need to be consistent with them.
You might be wondering why I picked that set of layers to use. It comes down to which things we want casting shadows onto which other things - we don't want the player casting a shadow on themself, but we do want them casting a shadow on the ground and on walls. Splitting the "lit" objects out from the occluders is useful when we get into the advanced lighting, but also helps to maintain sanity when trying to remember which things to set into each mask.
Clear your masks, save headaches
First things first, clear all of your light_masks . Start by having everything dark/unlit, so that you can add things back in one at a time. If you've got a stray mask layer, changing something might not have any visible effect and it'll drive you nuts.
Go through your lights, sprites, and occluders and clear out every kind of light_mask, occluder_mask, and item_cull_mask - start from a blank slate on all of them.
What the heck are all these masks?
Visibility -> light_mask
This is the list of layers that the node is in. This demo is set up so that each node is only in ONE layer - use the layer names to keep things from getting confusing.
Range -> item_cull_mask
This is only on Light2Ds, and it's the list of layers that the light will brighten. item_cull_mask
is a very confusing name - this mask includes the layers that we want to light, forget that it says cull
. If a layer is selected in this mask, it'll get lit.
Shadows -> item_cull_mask
This is only on Light2Ds, and it does double-duty for shadows - it picks up layers with occluders as well as layers that the light will draw shadows onto.
That's right, the shadow mask does two things:
- any occluders in the specified layers will create shadows
- any sprites in the specified layers will get shadows cast onto them
Again, it's a very confusing name, forget it says cull
in the name. And keep reminding yourself that it does two things.
Occluder -> light_mask
This is only on LightOccluder2Ds, and it's actually linked/aliased to the Visibility
-> light_mask
- editing one automatically edits the other!
Since they're linked, this is just the same as what we have for sprites - it's simply the list of layers that the node is in.
Aside - Tilemaps
Tilemaps are a little bit more complex than just sprites - they have both a light_mask
and an occluder_mask
. Think of them like a sprite with a built-in occluder node, and the masks work exactly the same as above. I'm not using a tilemap in this example, but so long as you set the masks the same way it works fine.
Back to the Example
We named layers 1-7 above, time to set up some masks on everything!
First, the Light2D
Set the Range
-> item_cull_mask
to the layers that you want to be lighting up (ignore shadows and occluders):
Set the Shadow
-> item_cull_mask
to the occluder layers that you want to interacting with, AND the layers that we want to draw sprites onto!
For this demo, we're including the background_occluders
, block_occluders
, and player_occluder
layers, AND the background_lit
layer.
Leave the light2d's light_mask empty, it doesn't need one!
Why not include the other lit
layers?
The problem with these masks is that we don't get any control over sprites or occluders interacting with other sprites or occluders - the 2d lighting system is all only things interacting with the Light2D. If we tried adding out block_lit
layer into the light's shadow mask, it would start getting shadows from its own occluder right away, which we don't want. We'll be adding a hack later to get better shadows, but for now, this light only gets to draw shadows onto the background.
Set up the rest
Since we cleared all of the masks, nothing visible will happen just yet - let's fix that!
Set the Sprite light_masks
- background sprite: light mask is layer
2
(background_lit
) and nothing else - block sprite: light mask is layer
4
(block_lit
) and nothing else - player sprite: light mask is layer
6
(player_lit
) and nothing else
As you set each of these, you should see it light up in the editor!
Set the Occluder light_masks
- block occluder: light_mask is layer
5
(block_occluder
) and nothing else - player occluder: light_mask is layer
7
(player_occluder
) and nothing else - I'm not using a tilemap, but if I was, I'd be setting its occluder light_mask to layer
3
(background_occluder
) and nothing else
Checking our progress
At this point, we should have nicely lit sprites as well as nice shadows:
Play around with this, you can see the light affecting the blocks and player as if they were 3d. This might be good enough for some games, but I really wanted to understand more of what was going on.
A simple explanation of Godot's 2D Lighting
With all of these masks, why can't we do something complex like casting player shadows onto blocks but not block shadows?
It's because everything comes back to interacting with the single light. The Light2D is taking information and doing some clever things to fake lights and shadows - it's not actually simulating anything! I haven't actually debugged the internals, but based on experimentation, I believe there's a single light mask for the whole light, and it's using the layers to update the mask, then using that mask to draw shadows onto nodes.
Here's a diagram that hopefully helps explain:
Godot takes the light texture, "punches out" holes for any occluders that we've included via our shadow item_cull_mask, then projects shadows and punches those out too. My diagram here is 1-bit, while godot is actually using gradients. Either way, we're dealing with a mask texture by the time shadows are getting drawn, and we've lost all information about the things that cast them.
Getting the Player shadow onto the blocks
Okay, but if that's true, how can we possibly cast shadows onto something that is itself casting shadows?
Simple - use a second light! With a second Light2D, we get a second, separate light mask, and we can re-use our occluders and layers to very quickly set it up in exactly the way that we want. As a bonus, we can tweak some of the other light settings at the same time if we want these on-the-walls shadows to look a bit different.
Duplicate your original Light2D and move the new one to be a child of the original - that way they can be treated like a single light source while editing.
Set the Range
-> item_cull_mask
to only include the block_lit layer, since that's the only thing this light will be lighting. The light needs to be lighting the thing up in order to cast shadows onto it.
Set the Shadow
-> item_cull_mask
to include the player_occluder
layer and the background_occluder
layer but NOT the block_occluder
layer (since that would mean blocks casting shadows on themselves - see why the layer names are helpful?). Remember to also include the block_lit
layer in the shadow mask here, since that's how we control what the shadows get cast onto!
Finally, we've accomplished the goal! Shadows on the floor, shadows on the walls! It doesn't look like all that much in this screenshot, but makes things feel a lot nicer when you've moving the player around:
Getting fancier
At this point we've got a good understanding and some nice layer names for adding more complex lighting situations. Some ideas:
- add a light that only affects the
player_lit
layer - use this to add some highlighting or make the player stand out more in a dark area - add lights that affect the background but not the player
- add additional coloured lights to give the area more appeal
- add drop-shadow sprites under the blocks to make them a bit more concrete
- use a rim lighting shader and a light that doesn't affect the
player_lit
layer to get a backlit effect - use black semi-transparent sprites to make certain areas darker
- set up some props with their own light that casts shadows onto the player
Here's a screenshot where I've tried out a few of these - not too shabby!
A note on performance
Something you'll inevitably find is that adding extra lights slows down godot in version 3. Since I'm only dealing with a few lights and I'm not worrying about building for mobile, it's not too likely to have a big impact for me. Still, it's a good idea to just a plain sprite to brighten/darken things wherever you can to avoid all this extra complexity and the extra draw calls.
Conclusion
Godot's light2d system is fairly powerful, once you wrap your head around how it actually works and what all of the masks are for. I'm looking forward to applying this in a real game rather than just a demo project.
Update: Godot 4 promises some big improvements to performance on lights, and splits a couple of these masks apart to give more flexibility. But the basics are all still there - 2d lights and shadows still interact per-light, and the masks control what's included and excluded. I'll come back and update this once godot 4 is fully released.