Hello and welcome back to the Blightmare development blog! Today's post is about how we went about automatically generating shadow casters for our terrain. In order to frame what I'm talking about, I think a picture is the best way. Below, you can see a test scene with shadow casting enabled for some of the platforms. In the small cave area, you can see that the terrain is not casting a shadow. It's most noticeable in the small floating piece of terrain.
Clearly we need to do something about this! Imagine a much deeper cave, or other terrain configuration which would benefit greatly from shadowing. I actually decided that if we couldn't get this to work that we should strongly consider not trying to do lighting at all. Fortunately - as we will see - it ended up working out alright.
The way 2D shadow casters work in Unity is:
- There's a component called ShadowCaster2D
- This component defines a path which is composed of an ordered set of points
- The runtime then builds a mesh out of these points by connecting them with lines and triangulating
- The mesh is then fed into the render pipeline as a light occluder and a shadow is computed
The goal for terrain shadow casting in Blightmare is a system that automatically generates a shadow caster mesh based on the placed terrain tiles that we have. Unfortunately, the built-in component does not expose any way for external scripts to inject a path or mesh, nor does it provide any extension points at all. After reading through both the documentation and the source for this system, I was about to call it quits, and I was complaining to the rest of the team because I really think that dynamic lighting is going to add a lot to Blightmare. While complaining, I ended up writing out the solution to the problem: just modify the plugin code to expose a hook.
This sounds simple enough, and in the end it is, but it involved making a complete local duplicate of the entire universal pipeline package which is quite a lot of things. It's not a super big deal, but it is somewhat annoying. Anyway, once that was done, I exposed a way to set the shadow caster path programmatically and we were back in business.
Armed with a way to actually set a path, all that we needed was the actual path itself. The path required is actually the outline of the terrain. In order to generate the outline, I came up with what is probably a naive algorithm, but it's simple and this can be done offline (like our previous terrain tooling). The way it works is:
- Pick any point "inside" the terrain - we have a big list of them which we store as part of the level data, so I just pick the first one
- Move up - (0, 1) - until moving up would exit the terrain
- Record this as the first point in our path
- Start moving along the edge of the terrain by tracking both the direction we're moving in, and which direction we expect to be empty
- If we're about to exit the terrain or there is terrain in the direction we expect to be empty, then we've found a corner
- Add the corner to our path
- Compute which direction to turn: there's 4 choices for both types of corner. Interior corners have 3 adjacent empty tiles, while exterior corners have 5
- Continue until we find our original path point
It took a couple tries to get this working correctly, but once I just sat down and wrote out all the cases, it was working. Unfortunately, there was a pretty big problem. This algorithm can only find the perimeter of a single piece of terrain. If there's islands of terrain - like in the test scene - then we'll only travel to one of them. In order to address this problem, I decided to just remove all the points of terrain that were inside the path that was computed, and re-run the algorithm on the remaining points. Once there were 0 points left, then we were done.
How do we remove all the points of terrain inside a path? We know that these points combine to form an island. This means that we want to remove all points that touch each other. This is something that a flood-fill algorithm can identify. There are several ways to implement a flood-fill algorithm, but I again went for simplicity over speed and implemented the following:
- Pick any point to start - the same point from the outline algorithm works nicely
- Add this point to a queue of points to process
- Grab a point off the queue and remove it from the list of all points
- Then scan in all directions (Up, Down, Left, Right) and enqueue those points if they are valid (contained in our list of all points)
- Continue grabbing points until there are none left
With both of these steps implemented, we can finally generate shadow caster outlines for all islands of terrain. Due to the way our perspective works, we actually want the shadow casting to be slightly inset from the edges of the art. After making this adjustment, we end up with the following computed shapes:
And finally, turning the lights back on, we get the result that we were hoping for at the start: