In this part of the series, we'll start working towards making it possible for objects to not only interact physically with the tilemap alone, but also with any other object, by implementing a collision detection mechanism between the game objects.
Demo
The demo shows the end result of this tutorial. Use WASD to move the character. The middle mouse button spawns a one-way platform, the right mouse button spawns a solid tile, and the spacebar spawns a character clone. The sliders change the size of the player's character. The objects that detect a collision are made semi-transparent.
The demo has been published under Unity 5.4.0f3, and the source code is also compatible with this version of Unity.
Collision Detection
Before we speak about any kind of collision response, such as making it impossible for the objects to go through each other, we first need to know whether those particular objects are overlapping.
This might be a very expensive operation if we simply checked each object against every other object in the game, depending on how many active objects the game currently needs to handle. So to alleviate our players' poor processor a little bit, we'll use...
Spatial Partitioning!
This basically is splitting the game's space into smaller areas, which allows us to check the collisions between objects belonging only to the same area. This optimization is severely needed in games like Terraria, where the world and the number of possible colliding objects is huge and the objects are sparsely placed. In single-screen games, where the number of objects is heavily restricted by the size of the screen, it often is not required, but still useful.
The Method
The most popular spatial partitioning method for 2D space is quad tree; you can find its description in this tutorial. For my games, I'm using a flat structure, which basically means that the game space is split into rectangles of a certain size, and I'm checking for collisions with objects residing in the same rectangular space.
There's one nuance to this: an object can reside in more than one sub-space at a time. That's totally fine—it just means that we need to detect objects belonging to any of the partitions our former object overlaps with.
Data for the Partitioning
The base is simple. We need to know how big each cell should be and a two-dimensional array, of which each element is a list of objects residing in a particular area. We need to place this data in the Map class.
public int mGridAreaWidth = 16; public int mGridAreaHeight = 16; public List<MovingObject>[,] mObjectsInArea;
In our case, I decided to express the size of the partition in tiles, and so each partition is 16 by 16 tiles big.
For our objects, we'll want a list of areas that the object is currently overlapping with, as well as its index in each partition. Let's add these to the MovingObject
class.
public List<Vector2i> mAreas = new List<Vector2i>(); public List<int> mIdsInAreas = new List<int>();
Instead of two lists, we could use a single dictionary, but unfortunately the performance overhead of using complex containers in the current iteration of Unity leaves much to be desired, so we'll stick with the lists for the demo.
Initialize Partitions
Let's move on to calculate how many partitions we need to cover the entire area of the map. The assumption here is that no object can float outside the map bounds.
mHorizontalAreasCount = Mathf.CeilToInt((float)mWidth / (float)mGridAreaWidth); mVerticalAreasCount = Mathf.CeilToInt((float)mHeight / (float)mGridAreaHeight);
Of course, depending on the map size, the partitions need not exactly match the map bounds. That's why we're using a ceiling of calculated value to ensure we have at least enough to cover the whole map.
Let's initiate the partitions now.
mObjectsInArea = new List<MovingObject>[mHorizontalAreasCount, mVerticalAreasCount]; for (var y = 0; y < mVerticalAreasCount; ++y) { for (var x = 0; x < mHorizontalAreasCount; ++x) mObjectsInArea[x, y] = new List<MovingObject>(); }
Nothing fancy happening here—we just make sure that each cell has a list of objects ready for us to operate on.
Assign Object's Partitions
Now it's time to make a function which will update the areas a particular object overlaps.
public void UpdateAreas(MovingObject obj) { }
First off, we need to know which map tiles the object overlaps with. Since we're only using AABBs, all we need to check is what tile each corner of the AABB lands on.
var topLeft = GetMapTileAtPoint(obj.mAABB.center + new Vector2(-obj.mAABB.HalfSize.x, obj.mAABB.HalfSizeY)); var topRight = GetMapTileAtPoint(obj.mAABB.center + obj.mAABB.HalfSize); var bottomLeft = GetMapTileAtPoint(obj.mAABB.center - obj.mAABB.HalfSize); var bottomRight = new Vector2i();
Now to get the coordinate in the partitioned space, all we need to do is divide the tile position by the size of the partition. We don't need to calculate the bottom right corner partition right now, because its x coordinate will be equal to the top right corner's, and its y coordinate will be equal to the bottom left's.
topLeft.x /= mGridAreaWidth; topLeft.y /= mGridAreaHeight; topRight.x /= mGridAreaWidth; topRight.y /= mGridAreaHeight; bottomLeft.x /= mGridAreaWidth; bottomLeft.y /= mGridAreaHeight; bottomRight.x = topRight.x; bottomRight.y = bottomLeft.y;
This all should work based on the assumption that no object will be moved outside of the map bounds. Otherwise we'd need to have an additional check here to ignore the objects which are out of bounds.
Now, it is possible that the object resides entirely in a single partition, it may reside in two, or it can occupy the space right where four partitions meet. This is under the assumption that no object is bigger than the partition size, in which case it could occupy the whole map and all the partitions if it was big enough! I've been operating under this assumption, so that's how we're going to handle this in the tutorial. The modifications for allowing bigger objects are quite trivial, though, so I'll explain them as well.
Let's start by checking which areas the character overlaps with. If all the corner's partition coordinates are the same, then the object occupies just a single area.
if (topLeft.x == topRight.x && topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); }
If that's not the case and the coordinates are the same on the x-axis, then the object overlaps with two different partitions vertically.
if (topLeft.x == topRight.x && topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); } else if (topLeft.x == topRight.x) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(bottomLeft); }
If we were supporting objects that are bigger than partitions, it'd be enough if we simply added all partitions from the top left corner to the bottom left one using a loop.
The same logic applies if only the vertical coordinates are the same.
if (topLeft.x == topRight.x && topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); } else if (topLeft.x == topRight.x) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(bottomLeft); } else if (topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(topRight); }
Finally, if all the coordinates are different, we need to add all four areas.
if (topLeft.x == topRight.x && topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); } else if (topLeft.x == topRight.x) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(bottomLeft); } else if (topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(topRight); } else { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(bottomLeft); mOverlappingAreas.Add(topRight); mOverlappingAreas.Add(bottomRight); }
Before we move on with this function, we need to be able to add and remove the object from a particular partition. Let's create these functions, starting with the adding.
public void AddObjectToArea(Vector2i areaIndex, MovingObject obj) { var area = mObjectsInArea[areaIndex.x, areaIndex.y]; //save the index of the object in the area obj.mAreas.Add(areaIndex); obj.mIdsInAreas.Add(area.Count); //add the object to the area area.Add(obj); }
As you can see, the procedure is very simple—we add the index of the area to the object's overlapping areas list, we add the corresponding index to the object's ids list, and finally add the object to the partition.
Now let's create the removal function.
public void RemoveObjectFromArea(Vector2i areaIndex, int objIndexInArea, MovingObject obj) { }
As you can see, we'll use the coordinates of the area that the character is no longer overlapping with, its index in the objects list within that area, and the reference to the object we need to remove.
To remove the object, we'll be swapping it with the last object in the list. This will require us to also make sure that the object's index for this particular area gets updated to the one our removed object had. If we did not swap the object, we would need to update the indexes of all the objects that go after the one we need to remove. Instead, we need to update only the one we swapped with.
Having a dictionary here would save a lot of hassle, but removing the object from an area is an operation that is needed far less frequently than iterating through the dictionary, which needs to be done every frame for every object when we are updating the object's overlapping areas.
//swap the last item with the one we are removing var tmp = area[area.Count - 1]; area[area.Count - 1] = obj; area[objIndexInArea] = tmp;
Now we need to find the area we are concerned with in the areas list of the swapped object, and change the index in the ids list to the index of the removed object.
var tmpIds = tmp.mIdsInAreas; var tmpAreas = tmp.mAreas; for (int i = 0; i < tmpAreas.Count; ++i) { if (tmpAreas[i] == areaIndex) { tmpIds[i] = objIndexInArea; break; } }
Finally, we can remove the last object from the partition, which now is a reference to the object we needed to remove.
area.RemoveAt(area.Count - 1);
The whole function should look like this:
public void RemoveObjectFromArea(Vector2i areaIndex, int objIndexInArea, MovingObject obj) { var area = mObjectsInArea[areaIndex.x, areaIndex.y]; //swap the last item with the one we are removing var tmp = area[area.Count - 1]; area[area.Count - 1] = obj; area[objIndexInArea] = tmp; var tmpIds = tmp.mIdsInAreas; var tmpAreas = tmp.mAreas; for (int i = 0; i < tmpAreas.Count; ++i) { if (tmpAreas[i] == areaIndex) { tmpIds[i] = objIndexInArea; break; } } //remove the last item area.RemoveAt(area.Count - 1); }
Let's move back to the UpdateAreas function.
We know which areas the character overlaps this frame, but last frame the object already could have been assigned to the same or different areas. First, let's loop through the old areas, and if the object is no longer overlapping with them then let's remove the object from these.
var areas = obj.mAreas; var ids = obj.mIdsInAreas; for (int i = 0; i < areas.Count; ++i) { if (!mOverlappingAreas.Contains(areas[i])) { RemoveObjectFromArea(areas[i], ids[i], obj); //object no longer has an index in the area areas.RemoveAt(i); ids.RemoveAt(i); --i; } }
Now let's loop through the new areas, and if the object hasn't been previously assigned to them, let's add them now.
for (var i = 0; i < mOverlappingAreas.Count; ++i) { var area = mOverlappingAreas[i]; if (!areas.Contains(area)) AddObjectToArea(area, obj); }
Finally, clear the overlapping areas list so it's ready to process the next object.
mOverlappingAreas.Clear();
That's it! The final function should look like this:
public void UpdateAreas(MovingObject obj) { //get the areas at the aabb's corners var topLeft = GetMapTileAtPoint(obj.mAABB.center + new Vector2(-obj.mAABB.HalfSize.x, obj.mAABB.HalfSizeY)); var topRight = GetMapTileAtPoint(obj.mAABB.center + obj.mAABB.HalfSize); var bottomLeft = GetMapTileAtPoint(obj.mAABB.center - obj.mAABB.HalfSize); var bottomRight = new Vector2i(); topLeft.x /= mGridAreaWidth; topLeft.y /= mGridAreaHeight; topRight.x /= mGridAreaWidth; topRight.y /= mGridAreaHeight; bottomLeft.x /= mGridAreaWidth; bottomLeft.y /= mGridAreaHeight; bottomRight.x = topRight.x; bottomRight.y = bottomLeft.y; //see how many different areas we have if (topLeft.x == topRight.x && topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); } else if (topLeft.x == topRight.x) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(bottomLeft); } else if (topLeft.y == bottomLeft.y) { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(topRight); } else { mOverlappingAreas.Add(topLeft); mOverlappingAreas.Add(bottomLeft); mOverlappingAreas.Add(topRight); mOverlappingAreas.Add(bottomRight); } var areas = obj.mAreas; var ids = obj.mIdsInAreas; for (int i = 0; i < areas.Count; ++i) { if (!mOverlappingAreas.Contains(areas[i])) { RemoveObjectFromArea(areas[i], ids[i], obj); //object no longer has an index in the area areas.RemoveAt(i); ids.RemoveAt(i); --i; } } for (var i = 0; i < mOverlappingAreas.Count; ++i) { var area = mOverlappingAreas[i]; if (!areas.Contains(area)) AddObjectToArea(area, obj); } mOverlappingAreas.Clear(); }
Detect Collision Between Objects
First of all, we need to make sure to call UpdateAreas
on all the game objects. We can do that in the main update loop, after each individual object's update call.
void FixedUpdate() { for (int i = 0; i < mObjects.Count; ++i) { switch (mObjects[i].mType) { case ObjectType.Player: case ObjectType.NPC: ((Character)mObjects[i]).CustomUpdate(); mMap.UpdateAreas(mObjects[i]); break; } } }
Before we create a function in which we check all collisions, let's create a struct which will hold the data of the collision.
This will be very useful, because we'll be able to preserve the data as it is at the time of collision, whereas if we saved only the reference to an object we collided with, not only would we have too little to work with, but also the position and other variables could have changed for that object before the time we actually get to process the collision in the object's update loop.
public struct CollisionData { public CollisionData(MovingObject other, Vector2 overlap = default(Vector2), Vector2 speed1 = default(Vector2), Vector2 speed2 = default(Vector2), Vector2 oldPos1 = default(Vector2), Vector2 oldPos2 = default(Vector2), Vector2 pos1 = default(Vector2), Vector2 pos2 = default(Vector2)) { this.other = other; this.overlap = overlap; this.speed1 = speed1; this.speed2 = speed2; this.oldPos1 = oldPos1; this.oldPos2 = oldPos2; this.pos1 = pos1; this.pos2 = pos2; } public MovingObject other; public Vector2 overlap; public Vector2 speed1, speed2; public Vector2 oldPos1, oldPos2, pos1, pos2; }
The data which we save is the reference to the object we collided with, the overlap, the speed of both objects at the time of collision, their positions, and also their positions just before the time of collision.
Let's move to the MovingObject
class and create a container for the freshly created collision data which we need to detect.
public List<CollisionData> mAllCollidingObjects = new List<CollisionData>();
Now let's go back to the Map
class and create a CheckCollisions
function. This will be our heavy duty function where we detect the collisions between all the game objects.
public void CheckCollisions() { }
To detect the collisions, we'll be iterating through all the partitions.
for (int y = 0; y < mVerticalAreasCount; ++y) { for (int x = 0; x < mHorizontalAreasCount; ++x) { var objectsInArea = mObjectsInArea[x, y]; } }
For each partition, we'll be iterating through every object within it.
for (int y = 0; y < mVerticalAreasCount; ++y) { for (int x = 0; x < mHorizontalAreasCount; ++x) { var objectsInArea = mObjectsInArea[x, y]; for (int i = 0; i < objectsInArea.Count - 1; ++i) { var obj1 = objectsInArea[i]; } } }
For each object, we check every other object that is further down the list in the partition. This way we'll check each collision only once.
for (int y = 0; y < mVerticalAreasCount; ++y) { for (int x = 0; x < mHorizontalAreasCount; ++x) { var objectsInArea = mObjectsInArea[x, y]; for (int i = 0; i < objectsInArea.Count - 1; ++i) { var obj1 = objectsInArea[i]; for (int j = i + 1; j < objectsInArea.Count; ++j) { var obj2 = objectsInArea[j]; } } } }
Now we can check whether the AABBs of the objects are overlapping each other.
Vector2 overlap; for (int y = 0; y < mVerticalAreasCount; ++y) { for (int x = 0; x < mHorizontalAreasCount; ++x) { var objectsInArea = mObjectsInArea[x, y]; for (int i = 0; i < objectsInArea.Count - 1; ++i) { var obj1 = objectsInArea[i]; for (int j = i + 1; j < objectsInArea.Count; ++j) { var obj2 = objectsInArea[j]; if (obj1.mAABB.OverlapsSigned(obj2.mAABB, out overlap)) { } } } } }
Here's what happens in the AABB's OverlapsSigned
function.
public bool OverlapsSigned(AABB other, out Vector2 overlap) { overlap = Vector2.zero; if (HalfSizeX == 0.0f || HalfSizeY == 0.0f || other.HalfSizeX == 0.0f || other.HalfSizeY == 0.0f || Mathf.Abs(center.x - other.center.x) > HalfSizeX + other.HalfSizeX || Mathf.Abs(center.y - other.center.y) > HalfSizeY + other.HalfSizeY) return false; overlap = new Vector2(Mathf.Sign(center.x - other.center.x) * ((other.HalfSizeX + HalfSizeX) - Mathf.Abs(center.x - other.center.x)), Mathf.Sign(center.y - other.center.y) * ((other.HalfSizeY + HalfSizeY) - Mathf.Abs(center.y - other.center.y))); return true; }
As you can see, if an AABB's size on any axis is zero, it cannot be collided with. The other thing you could notice is that even if the overlap is equal to zero, the function will return true, as it will reject the cases in which the gap between the AABBs is larger than zero. That's mainly because if the objects are touching each other and not overlapping, we still want to have the information that this is the case, so we need this to go through.
As the last thing, once the collision is detected, we calculate how much the AABB overlaps with the other AABB. The overlap is signed, so in this case if the overlapping AABB is on this AABB's right side, the overlap on the x axis will be negative, and if the other AABB is on this AABB's left side, the overlap on the x axis will be positive. This will make it easy later on to come out of the overlapping position, as we know in which direction we want the object to move.
Moving back to our CheckCollisions
function, if there was no overlap, that's it, we can move to the next object, but if an overlap occurred then we need to add the collision data to both objects.
if (obj1.mAABB.OverlapsSigned(obj2.mAABB, out overlap)) { obj1.mAllCollidingObjects.Add(new CollisionData(obj2, overlap, obj1.mSpeed, obj2.mSpeed, obj1.mOldPosition, obj2.mOldPosition, obj1.mPosition, obj2.mPosition)); obj2.mAllCollidingObjects.Add(new CollisionData(obj1, -overlap, obj2.mSpeed, obj1.mSpeed, obj2.mOldPosition, obj1.mOldPosition, obj2.mPosition, obj1.mPosition)); }
To make things easy for us, we'll assume that the 1's (speed1, pos1, oldPos1) in the CollisionData structure always refer to the owner of the collision data, and the 2's are the data concerning the other object.
The other thing is, the overlap is calculated from the obj1's perspective. The obj2's overlap needs to be negated, so if obj1 needs to move left to move out of the collision, obj2 will need to move right to move out of the same collision.
There's still one small thing to take care of—because we're iterating through the map's partitions and one object can be in multiple partitions at the same, up to four in our case, it's possible that we'll detect an overlap for the same two objects up to four times.
To remove this possibility, we simply check whether we've already detected a collision between two objects. If that's the case, we skip the iteration.
if (obj1.mAABB.OverlapsSigned(obj2.mAABB, out overlap) && !obj1.HasCollisionDataFor(obj2)) { obj1.mAllCollidingObjects.Add(new CollisionData(obj2, overlap, obj1.mSpeed, obj2.mSpeed, obj1.mOldPosition, obj2.mOldPosition, obj1.mPosition, obj2.mPosition)); obj2.mAllCollidingObjects.Add(new CollisionData(obj1, -overlap, obj2.mSpeed, obj1.mSpeed, obj2.mOldPosition, obj1.mOldPosition, obj2.mPosition, obj1.mPosition)); }
The HasCollisionDataFor
function is implemented as follows.
public bool HasCollisionDataFor(MovingObject other) { for (int i = 0; i < mAllCollidingObjects.Count; ++i) { if (mAllCollidingObjects[i].other == other) return true; } return false; }
It simply iterates through all the collision data structures and looks up whether any already belong to the object we are about to check collision for.
This should be fine in general use case as we're not expecting an object to collide with many other objects, so looking through the list is going to be quick. However, in a different scenario it might be better to replace the list of CollisionData
with a dictionary, so instead of iterating we could tell right away if an element is already in or not.
The other thing is, this check saves us from adding multiple copies of the same collision to the same list, but if the objects are not colliding, we'll anyway be checking for overlap multiple times if both objects belong to the same partitions.
This shouldn't be a big concern, as the collision check is cheap and the situation is not that common, but if it were a problem, the solution might be to simply have a matrix of checked collisions or a two-way dictionary, fill it up as the collisions get checked, and reset it right before we call the CheckCollisions
function.
Now let's call the function we just finished in the main game loop.
void FixedUpdate() { for (int i = 0; i < mObjects.Count; ++i) { switch (mObjects[i].mType) { case ObjectType.Player: case ObjectType.NPC: ((Character)mObjects[i]).CustomUpdate(); mMap.UpdateAreas(mObjects[i]); mObjects[i].mAllCollidingObjects.Clear(); break; } } mMap.CheckCollisions(); }
That's it! Now all our objects should have the data about the collisions.
To test if everything works properly, let's make it so that if a character collides with an object, the character's sprite will turn semi-transparent.
As you can see, the detection seems to be working well!
Summary
That's it for another part of the simple 2D platformer physics series. We managed to implement a very simple spatial partitioning mechanism and detect the collisions between each object.
If you have a question, a tip on how to do something better, or just have an opinion on the tutorial, feel free to use the comment section to let me know!