2D Hard Shadows
This is an in-depth explanation about how I implemented hard shadows for Glass Bubble. To be clear, I am using C# for the following code examples.
Most explanations I found online about how to implement 2D hard shadows usually worked by making a quad mesh for every light with the shadows cut out of them. Instead, I approached the problem in a different way, partly because my friend convinced me it would be easier and I wanted a different graphical effect involving light shafts or god ray-like shadows. So, I did the opposite of what I had seen so many times online: create a mesh for each shadow.
The implementation is limited as it doesn't work for concave objects. I haven't addressed this problem yet since I do not expect to encounter this use case in the game.
Here is a summary of what I'll go over:
- Setup - How the code works overall and a few lines of setup code
- Finding Extremes - Finding the extremes on the object we want to cast a shadow with
- 2D Cross Product - An explanation of how the math works for finding the extremes
- Creating the mesh - Creating the mesh using the extremes
I expect you to have basic knowledge about Unity and some programming experience in order to follow along as I won't be explaining everything, although I hope to provide a clear explanation of how I established this effect.
Setup
I specify which GameObjects I want to cast hard
shadows with by adding the script
HardShadows.cs
to them. This script
contains the logic for creating the necessary
shadows. This script will also assume that the
GameObject I put it on has a mesh component on it
which defines the object's shape that it can
access.
In this script, we have a list of shadows that
the GameObject is currently casting. In the
Update()
loop, we delete all of those
shadows before generating the new ones. Then, we
grab a list of all of the lights in the scene that
we want to create a shadow with.
void Update() {
ClearShadows();
[] lights = GameObject.FindGameObjectsWithTag("Light");
GameObjectforeach(GameObject go in lights) {
= go.GetComponent<Light>();
Light light CreateShadowFromLight(light);
}
}
I've tagged each light that I want a shadow for with the tag "Light". I probably need a better name for this tag, but for now it will suffice. You could instead search for all GameObjects that use a Light component, but I only want to check certain lights, especially because I don't want all of the lights in a scene casting god rays everywhere (as cool as some may think that would be).
We use each light as a parameter to the
CreateShadowFromLight(Light)
function.
This call, which we will go over in the next
section, will create a new shadow for that light and
save it in the list of shadows.
As an aside, the Update()
code could
be optimized to clear and update the shadows only
when the GameObject actually does move.
Finding Extremes
Inside the function
CreateShadowFromLight(Light)
, we grab
the light's position and the vertices that make up
the GameObject's mesh.
= light.gameObject.transform.position;
Vector3 lightPos [] vertices = GetComponent<MeshFilter>().mesh.vertices; Vector3
We want to calculate where the left-most and right-most points on the GameObject's mesh are, respective to the light. We will call these points the extreme points. On lines 1 and 2 we treat the first vertex in the array as the left and right extreme, but we will find better solutions when we enter the for loop on line 5.
= this.transform.TransformPoint(vertices[0]);
Vector3 left = this.transform.TransformPoint(vertices[0]);
Vector3 right
// Calculate extremes
for(int i = 1; i < vertices.Length; i++) {
= this.transform.TransformPoint(vertices*);
Vector3 point
// Left-most
float crossProduct = (left.x-lightPos.x)*(point.y-lightPos.y) - (left.y-lightPos.y)*(point.x-lightPos.x);
if(crossProduct > 0) {
= point;
left }
// Right-most
= (right.x-lightPos.x)*(point.y-lightPos.y) - (right.y-lightPos.y)*(point.x-lightPos.x);
crossProduct if(crossProduct < 0) {
= point;
right }
}
On line 5, we start at index 1 instead of 0 because we are already using the first vertex in the array as the starting value.
On lines 10 and 16, we calculate whether or not the point we are looking at is a better left or right extreme then the one we currently have. The math behind calculating whether or not the point is to the left or to the right of another point respective to the light's perspective might as well be black magic to me, but I will attempt to explain it in the next section.
2D Cross Product
First of all, we don't need to bother checking whether or not the point can be seen by the camera or not. In other words, we don't need to check if the point is on a face that is not facing the light. This is because from the light's perspective, points that are on faces not facing the light will always be between the left and right extremes.
What we do need to be able to figure out is whether or not a point is to the right or to the left of another. To do this we'll do a 2D cross product. Yes, a 2D cross product. 2D cross products don't actually exist, but we can derive some interesting properties from the operation.
To get the equation that I used earlier, we start with the 3D cross product:
*b = (a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x) a
a
and b
are our two
vectors. The result of the cross product is another
vector.
However, since we are in 2D, we will assume that the z value for both vectors is 0. This results in the following equation:
*b = (0, 0, a.x*b.y - a.y*b.x) a
We can ignore the x and y components of the resulting vector and define the result of a 2D cross product to be just the value of the z component of that vector:
*b = a.x*b.y - a.y*b.x a
The result of this equation has a few useful properties such as figuring out the area of a parallelogram formed by the two vectors or whether or not rotating from vector a to vector b is in the clockwise or counter-clockwise direction. The latter property is the one we will use later.
I then use the vector from the light to the
current extreme and the vector from the light to the
point we want to check as the a
and
b
vectors. To get these vectors, you
subtract the light's position from the extreme's or
point's position.
= extreme-light
a = point-light b
So now we have defined vector a
as
the extreme's position minus the light's position
and vector b
as the position of the
point we want to check minus the light's position.
If we look at the values for each of the components
in each vector, we get the following values:
.x = extreme.x - lightPos.x
a.y = extreme.y - lightPos.y
a.x = point.x - lightPos.x
b.y = point.y - lightPos.y b
If we substitute those values in for the formula we got earlier, we get:
*b = (extreme.x-lightPos.x)*(point.y-lightPos.y) - (extreme.y-lightPos.y)*(point.x-lightPos.x); a
And that's how I got to that formula, except with
extreme
substituted with which extreme
we want to check with.
Finally, we check the result of that equation. If the result is less than 0, the point is to the left of our extreme. If it is more than 0, the point is to the right of our extreme. In either case, the point we are checking is a better extreme then we had, so we save that as our new extreme value.
Creating the mesh
After we find the extreme points on the GameObject, we make a list of points that we want to use for our shadow mesh. The first two points are the left and right extreme (order is important). We also need to figure out where the distant point is. The distant point is the point we would get if we drew a line from the light to the extreme and continued into infinity.
// Create the mesh
<Vector2> verts = new List<Vector2>(4);
List.Add(left);
verts.Add(right);
verts.Add(right + (right-lightPos).normalized * 9999);
verts.Add(left + (left-lightPos).normalized * 9999); verts
The math for getting the distant points is a little bit simpler. The difference between the light's position and the right or left extreme will give us the vector from the light to that extreme. All we have to do after that is normalize it (in case the vector is extremely small) and multiply it by some sufficiently large number.
Then, we create the mesh.
.CreateMesh(CreateShadow(alpha).mesh, verts); MeshCreator
CreateShadow(float)
is another
method within the HardShadows.cs
file
that creates a GameObject to store the shadow mesh,
assigns the material, and sets the color of the
shadow. The only significant thing to note is that
the GameObject's x and y position is 0. This is
because the mesh's coordinates will already be in
the correct location. This also means that the
GameObject we created to hold the shadow can't be a
child of any other GameObjects that have an altered
position, rotation, or scale.
MeshCreator
is a piece of utility
code that I wrote to wrap a call to the Triangulator
script from the Unity Wiki. You can find the
code for MeshCreator.cs
below:
using UnityEngine;
using System.Collections.Generic;
public class MeshCreator {
public static Mesh CreateMesh(Mesh mesh, List<Vector2> points) {
// Save verticies
<Vector3> verticies = new List<Vector3>();
Listforeach(Vector3 vec in points) {
.Add(vec);
verticies}
.vertices = verticies.ToArray();
mesh
// Save UVs
[] uvs = new Vector2[points.Count];
Vector2for(int i = 0; i < points.Count; i++) {
* = new Vector2(0, 0);
uvs}
.uv = uvs;
mesh
// Save triangles
= new Triangulator(points.ToArray());
Triangulator tri .triangles = tri.Triangulate();
mesh
return mesh;
}
}
And that is how I create shadows in Glass Bubble! I hope you found this post interesting or informative.