Introduction and inset colliders
Why a kinematic collider?
Have you ever struggled to build a 2D controller in Unity, trying to make it behave the way you want, making concessions just to have something working, even if not quite right?
I have, multiple times. While most of the time I was able to get things almost the way I wanted, I spent considerable time polishing details, trying to achieve exactly what I had in mind. However, I was never able to get it perfect down to the smallest detail.
So I decided to test another approach: a fully kinematic collider that moves exactly the way I want. The physics engine is never used to drive the movement itself — only for queries, like casts and overlaps. This lets me build everything with maximum precision, with a collider responding exactly as I intend, with no forces outside my direct control.
Testing collisions
When discussing controllers, there’s a key question worth addressing: can the controller perform a specific movement? This is a challenging question that I will attempt to answer in this article.
Let’s start with a simple scenario. My controller is jumping and is about to collide with a wall.
This case is straightforward: we simply cast the controller’s collider and check how far it can travel before hitting the wall.
Another case occurs when the controller is moving along a surface it’s already in contact with.
With perfect accuracy, we could do the same: cast the controller’s collider, check if it collides with the ground, and — since it does not — move the controller forward. Sadly, this isn’t possible because even the tiniest penetrations between the collider and the ground would cause the cast to register an immediate collision.
Here, we need some margin of error, and an easy solution is to slightly inset the collider. This raises a natural question: could we use the same strategy in the first case? Technically, yes, but doing so would create some penetration between the collider and the wall.
This is a significant problem because the collider now fails to work properly in either case. However, there are multiple ways to address this. I chose to solve the issue using two different approaches.
The simplest approach is depenetration. After movement, if two colliders overlap, I apply a corrective displacement to the controller to eliminate the penetration. This is straightforward to implement: simply add the MTV (Minimum Translation Vector) to the collider’s coordinates.
This works quite well in most cases, but it has some caveats. First, if a collider is moving too fast or is too thin, it may pass through another collider entirely. Second, if depenetration is the only mechanism to prevent overlaps, the controller must be depenetrated after every tiny movement. Furthermore, relying solely on depenetration can lead to situations where perfect depenetration becomes impossible — when a collider penetrates multiple other colliders simultaneously, applying the MTV from one can worsen the penetration with another.
However, it remains a solid solution in most scenarios.
The second approach is to account for the collider’s skin width when calculating movement. In practice, we cast the inset collider, determine how far it can travel, and then subtract the skin thickness in the direction of movement from the total allowable distance.
This solution works very well in most situations, but some problems remain. For example, suppose a rectangular controller is jumping while moving toward a wall. Most of the time there’s no issue, but when the player gets very close to the corner, penetration is still possible.
While calculating whether one collider hits another is straightforward, determining when to use the inset collider versus the outset collider quickly becomes complex. I experimented extensively with this problem and developed several approaches, but ultimately, collisions requiring this distinction are rare enough that the added complexity isn’t justified. I may revisit this issue later during development.
The issues arising from the naive approach are typically minor and fully resolved by a depenetration pass. However, there is one significant edge case: if a hole is too small, the collider can become stuck inside when perfectly aligned to fit. This occurs only when holes measure between and . Since the skin width is a small value, this creates an extremely narrow range of problematic hole sizes — one that’s easily avoided through careful level design.
To manage movement, I temporarily adopted a combination of depenetration, clamped casting of the inner collider, and careful level design.
How I implemented all of this
The physics engine and the colliders
When I started working on this project, I used Physics2D as I always had. But as I added more features, I found myself fighting against the engine once again — exactly what I wanted to avoid from the start. So I decided to switch to the new Unity Physics Core 2D APIs.
I think it was a good decision. Working at a lower level than before, I was able to build my queries exactly as I wanted. I will discuss this in more detail in the relevant sections.
At this point, I still use Physics2D for collider authoring. This is a dependency I will probably remove at some stage, but for now it’s convenient not to have to redefine all the UI needed to configure the colliders.
As I mentioned earlier, I need two colliders: an outer collider and an inner one. To manage this, I created a component called ColliderWithSkin that creates the bodies needed to represent both colliders. This component creates two PhysicsBody instances, each containing all the shapes needed to represent the collider. Through experimentation, I found that a good value for the distance between the two colliders that works well with Physics Core 2D queries is 0.01.
I decided to inset the colliders automatically. For many shapes this is straightforward, and I implemented it manually. For path-based polygons, however, insetting can be quite complex, as new vertices may be created during the insetting process. Writing these kinds of algorithms by hand is not trivial — small errors are always possible — so I decided to use Clipper2 by Angus Johnson to implement it, sparing myself a headache or two by simply using Clipper.InflatePaths.
As I mentioned, insetting a polygon can create more vertices than the original, and Physics Core 2D has a maximum it can handle within a single polygon (8). Fortunately, Physics Core 2D provides a function to split longer vertex lists into smaller convex polygons, called PolygonGeometry.CreatePolygons. I iterated over every path to get the polygon to inset, then inset the polygon, creating a list of PolygonGeometry to add using PhysicsBody.CreateShapeBatch. While it’s not extremely important — since I rarely create these polygons — I also used various pools to reduce allocations as much as possible. The only important detail to note is that for the inset to work properly, the original polygons must not be split. So I used the original paths from the polygon I used for authoring. Fortunately, PhysicsBody.CreatePolygons also supports concave polygons for input (unlike basically anything else in Physics Core 2D), so the Physics2D paths work perfectly here. The rest of the code is trivial enough that I won’t include it here.
Depenetrate
Depenetrating a collider is straightforward once you have the MTV (Minimum Translation Vector) — the minimum displacement needed to separate one body from another. Simply add it to the collider’s position, and you’re done.
The real challenge lies in getting this vector. Fortunately, the necessary information is readily available by calling Intersect on any shape managed by Physics Core 2D. The following helper method is part of the KinematicCollider class, which contains all the methods required to move a ColliderWithSkin around.
Vector2? GetMtv(ColliderWithSkin collider, ColliderWithSkin.Type type)
{
// ...
}
Where ColliderWithSkin.Type is an enum with values Inner or Outer, indicating which component we are attempting to depenetrate, while collider contains a reference to the collider from which I want to depenetrate.
The algorithm begins by retrieving all contacts between the two colliders:
NativeList<PhysicsShape.ContactManifold> contacts = colliderWithSkin.Intersect(type, Transform, collider);
ColliderWithSkin.Intersect is a method that collects every PhysicsShape.ContactManifold between the two sets of shapes by calling the corresponding Intersect method and storing the results in a NativeList. The Transform property represents the current PhysicsTransform to use for colliderWithSkin, which is the collider controlled by the KinematicCollider. Using Transform, I was able to synchronize the colliders’ positions only after completing a series of movements.
The following lines are straightforward. Since I’ll be iterating through all contacts, I need a variable to track the best result found so far (initially there’s no contact, so no MTV). Then I iterate through all the contact manifolds and through all the points in each manifold, finding the minimum translation vector to return:
Vector2? mtv = null;
foreach (PhysicsShape.ContactManifold contact in contacts)
for (int i = 0; i < contact.pointCount; i++)
{
PhysicsShape.ContactManifold.ManifoldPoint point = contact.points[i];
// ...
}
return mtv;
The first thing I want to do is exclude any point that is not intersecting. Fortunately, that’s easy:
if (point.separation >= 0)
continue;
Then, I retrieve the next possible MTV:
Vector2 candidateMtv = contact.normal * point.separation;
I accept it only if I don’t have a shorter MTV:
if (mtv == null || candidateMtv.sqrMagnitude < mtv.Value.sqrMagnitude)
mtv = candidateMtv;
That is all we need. Here is the full method:
Vector2? GetMtv(ColliderWithSkin collider, ColliderWithSkin.Type type) {
NativeList<PhysicsShape.ContactManifold> contacts = colliderWithSkin.Intersect(type, Transform, collider);
Vector2? mtv = null;
foreach (PhysicsShape.ContactManifold contact in contacts)
for (int i = 0; i < contact.pointCount; i++) {
PhysicsShape.ContactManifold.ManifoldPoint point = contact.points[i];
if (point.separation >= 0)
continue;
Vector2 candidateMtv = contact.normal * point.separation;
if (mtv == null || candidateMtv.sqrMagnitude < mtv.Value.sqrMagnitude)
mtv = candidateMtv;
}
return mtv;
}
Clamped casting
This will be significantly more challenging, though. Calculating the width difference between the inner and outer collider is straightforward in simple cases (which are actually what I need most often); however, I wanted to create something that works in most situations, with any collider shape.
Again, calculating the movement itself is simple enough; the real challenge lies in calculating the correction — that is, 0 if there was no collision, or the distance between the two colliders at the contact point if there was a collision. But how do we calculate this?
A simple approach is to cast a ray from the point where the inner collider makes contact, in the direction of the collider’s sweep, to find where the ray intersects with the outer collider.
Notice that this procedure contains an approximation: I assume that the outer collision point is the point where the ray intersects the outer collider. This is not strictly accurate. During a sweep, the outer collider may touch an obstacle at a different point on its boundary before that ray-intersection point reaches the obstacle.
This can happen because the effective distance between the inner and outer boundaries is not necessarily the same in every direction or at every point on the shape. The ray measures that distance only along one specific direction, starting from the inner contact point. During the sweep, however, another part of the outer boundary may have a larger offset in the sweep direction. It may therefore reach an obstacle first, even though the ray from the inner contact point intersects the outer collider somewhere else.
Still, for my purposes, this local estimate is good enough. The inner and outer colliders are separated only by the skin width, which in my case is 0.01, and their shapes are nearly identical. As a result, the potential discrepancy between the raycast intersection and the true first contact point is extremely small. In practical terms, this error is negligible and does not affect the controller’s behavior in any noticeable way. The approximation trades perfect geometric accuracy for simplicity and performance, which is an acceptable compromise given such a small margin.
The entire procedure is based on calculating the maximum sweep movement the character can perform. For this, I call the following function (which in my KinematicCollider is an inner function):
float GetMaxMovement(PhysicsAABB aabb, WorldCastResult hit)
{
// ...
}
Regarding the PhysicsAABB, I want to explain how to retrieve it, as it is somewhat tricky. While retrieving an AABB from any PhysicsShape is straightforward, the result is enlarged by PhysicsWorld.aabbMargin. To get a precise AABB, I retrieve the AABB from all physics shapes, find the maximum and minimum bounds, and correct them by subtracting PhysicsWorld.aabbMargin. As you’ll see shortly, this correction isn’t strictly necessary for this particular case, but I created generic AABB retrieval code that requires this correction elsewhere in my codebase. Since my skin width is only 0.01 and the AABB margin defaults to 0.005 — exactly half the skin width — the uncorrected difference would introduce too much error for many of my calculations.
The idea is to cast a ray, but rays in Physics Core 2D aren’t true rays: they’re oriented line segments. This means I need to specify the segment’s length. Fortunately, I have a safe upper bound: the maximum distance a ray can travel within a shape is the diagonal of its bounding box. I add a small value to account for potential rounding errors and ensure the ray registers a hit:
float rayLength = (2 * aabb.extents).magnitude + 0.001f;
At this point, I need the world-space point on the inner collider that produced the sweep hit. There is one detail that is easy to miss: hit.point is reported at the moment of impact, as if the inner collider had already moved along the sweep. So I first calculate how far it traveled before that impact:
float distance = hit.fraction * innerDistance;
Then I use that distance to move the hit point back along the sweep direction, recovering the corresponding point on the collider in its initial position:
Vector2 rayOrigin = hit.point - direction * distance;
Now I need to cast the ray. First, the straightforward part: creating the structure that contains the ray data:
var rayInput = new CastRayInput(rayOrigin, direction * rayLength);
Then, I can cast the ray. Unfortunately, I can’t use the normal raycast for this because Physics Core 2D considers shapes as solid volumes rather than hollow contours. Casting a ray from inside would therefore register an immediate hit. Fortunately, the solution is straightforward. I generalized the normal raycast to support up to two attempts. If the first cast hits immediately, I simply invert the ray’s start and end points and try again. Ideally, my casting method would return PhysicsQuery.CastResult, just like a standard raycast, but both its members and constructor are inaccessible. So I created a wrapper type, RayCastResult, for my casts that contains the same information. The method ColliderWithSkin.GetClosestRayIntersection simply iterates through all shapes, casting the ray against each one, and returns the closest intersection it finds.
RayCastResult outerHit = colliderWithSkin.GetClosestRayIntersection(rayInput, Outer, Transform);
At this point, I can calculate the skin width in that specific direction by measuring the distance between the hit point (translated back to the original collider position) and the ray intersection point:
float skinWidth = Vector2.Distance(outerHit.Point, rayOrigin);
Of course, skinWidth has a valid value only if there was a hit. Now, we need to calculate and return the actual movement distance, corrected by skinWidth. The formula is straightforward: distance - skinWidth. I clamp the result between 0 and maxDistance (the maximum distance tested by the query). The lower bound of 0 is especially important: the distance can be negative if the outer collider is already penetrating the collider being tested in the sweep.
float movement = 0;
if (outerHit)
movement = Mathf.Clamp(distance - skinWidth, 0, maxDistance);
return movement;
Note that the movement is 0 when there’s no hit. This only occurs if the controller is already colliding with the tested collider, and in that case, I want it to remain stuck (depenetration will resolve the situation instead).
The complete code is:
float GetMaxMovement(PhysicsAABB aabb, WorldCastResult hit)
{
float rayLength = (2 * aabb.extents).magnitude + 0.001f;
float distance = hit.fraction * innerDistance;
Vector2 rayOrigin = hit.point - direction * distance;
var rayInput = new CastRayInput(rayOrigin, direction * rayLength);
RayCastResult outerHit = colliderWithSkin.GetClosestRayIntersection(rayInput, Outer, Transform);
float skinWidth = Vector2.Distance(outerHit.Point, rayOrigin);
float movement = 0;
if (outerHit)
movement = Mathf.Clamp(distance - skinWidth, 0, maxDistance);
return movement;
}
Final result
I have just walked through the core functions that enable the controller to move in any direction. Most other tasks are built on top of these foundations, and the controller already handles the majority of them — I’ll demonstrate more in upcoming articles. Of course, I have only provided glimpses of the complete codebase; nevertheless, I believe I’ve shared much of the essential information needed to build this type of movement system. I recognize that some aspects won’t be entirely clear without access to the full codebase, so if any part of what I’ve written remains unclear, and you’d like to understand it better, feel free to reach out.
Even at this stage, this approach already avoids a trade-off that is common with more typical controllers. Colliders that stay perfectly rectangular tend to snag on seams between adjacent tile colliders, so many controllers use a capsule shape or similar rounded solution to avoid or mitigate it — at the cost of a collider height that varies near edges and corners. More elaborate approaches can get both properties at once, but they are considerably more complex to implement correctly. Here, because collision is resolved through casts of the inner collider and depenetration rather than shape approximation, the collider keeps a constant height everywhere, corners included, without ever snagging on internal tile seams — without needing that extra complexity.
To see the final result of this specific article, watch the following video. I used the Free Platform Game Assets by Bayat Games from the Unity Asset Store.