Contact & Motion
The Shape Is the Collider
Veta is a kart racer for the Mac, and under it sits a 2D rigid-body engine I wrote rather than imported — shapes, contacts, a solver, a destructible terrain field, a character controller, all on one deterministic fixed step. This series walks through it, from the geometry up to the karts, which turn out to use almost none of it and are more interesting for that.
The first decision is the one everything else inherits. A collider is not a simplified stand-in for the art; it is the art. One shape, drawn and collided:
/// A single convex collision primitive in local space. Concave forms are built from
/// several of these in a `CompoundShape`. This is the exact geometry a renderer draws:
/// there is no separate art shape to drift out of step with the collider.
public enum Shape: Equatable, Sendable {
case circle(radius: Float)
case capsule(radius: Float, halfHeight: Float)
case polygon(ConvexPolygon)
}
Three cases, all convex. Anything concave — a star, a crescent — is a CompoundShape of several of them, collided part by part and drawn part by part. There is no mesh case and no concave polygon case, because every algorithm downstream assumes convexity and gets to stay small because of it.
A polygon that refuses to be built wrong
ConvexPolygon's initialiser is failable, and it does four things before it agrees to exist:
public init?(vertices input: [Vec2]) {
guard input.count >= 3, input.count <= Self.maxVertices else { return nil }
var area: Float = 0
for i in input.indices {
let a = input[i], b = input[(i + 1) % input.count]
area += a.cross(b)
}
guard abs(area) > Scalar.epsilon * Scalar.epsilon else { return nil }
let verts = area > 0 ? input : input.reversed()
// Convexity: every turn must be a left turn (or straight).
for i in verts.indices {
let a = verts[i], b = verts[(i + 1) % verts.count], c = verts[(i + 2) % verts.count]
if (b - a).cross(c - b) < -Scalar.epsilon { return nil }
}
self.vertices = verts
self.normals = verts.indices.map { i in
(verts[(i + 1) % verts.count] - verts[i]).perpCW.normalized
}
// ...
}
It rejects a degenerate polygon, rejects a concave one, reorders a clockwise one to counter-clockwise rather than rejecting it, and precomputes the outward edge normals. The reordering matters more than it looks: the winding is a load-bearing convention for everything after it. normals[i] is the outward normal of the edge from vertices[i] because the vertices run counter-clockwise, and the separating-axis code two posts from here reads those normals as the directions along which the polygon can be pushed away. A clockwise polygon slipped into the engine would have every normal pointing inward and would collide inside out — a bug that looks like gravity reversing for one object. Fixing the winding at construction means nothing downstream ever has to ask.
No scale, anywhere
A placed shape is a Transform2D: a rotation and a translation, with the trig cached, and deliberately nothing else.
/// A rigid 2D transform: a rotation (radians, counter-clockwise) followed by a
/// translation. No scale — a shape's size is the shape's business, and a scaled collider
/// would no longer be the geometry the renderer draws.
public struct Transform2D: Equatable, Sendable {
public var position: Vec2
public private(set) var cosA: Float
public private(set) var sinA: Float
private var storedAngle: Float
public var angle: Float {
get { storedAngle }
set {
storedAngle = newValue
cosA = cosf(newValue)
sinA = sinf(newValue)
}
}
}
Leaving scale out is the same decision as the first one, enforced. The moment a transform can scale, the renderer's scale and the collider's scale are two numbers that can disagree, and the promise that what you see is what you hit becomes a convention someone has to remember. A box that needs to be twice as wide is a different box.
The cached cosA/sinA are the cheap half of this. Rotating a point happens several times per shape per contact test; recomputing cosf each time is the kind of cost that hides inside a profile as a flat 3% everywhere rather than a spike anywhere.
The core hull: one idea that keeps the narrowphase small
A circle is a point that has been inflated by its radius. A capsule is a segment inflated by its radius. A polygon is inflated by nothing at all. Say that out loud and the three cases collapse into one:
/// The shape's un-rounded core in world space, plus the radius to fold back in.
public func coreHull(_ t: Transform2D) -> CoreHull {
switch self {
case .circle(let r):
return CoreHull(points: [t.position], radius: r)
case .capsule(let r, let h):
return CoreHull(points: [t.apply(Vec2(0, -h)), t.apply(Vec2(0, h))], radius: r)
case .polygon(let p):
return CoreHull(points: p.vertices.map { t.apply($0) }, radius: 0)
}
}
Everything the distance algorithms need from a shape is now points and a radius: find the gap between two point sets, subtract the two radii, and you have the gap between the real shapes. A capsule against a rotated heptagon is not a special case; it is a 2-point hull against a 7-point hull. That is why the next post, on the distance algorithm itself, has no shape-specific branches in it at all.
The one piece of shape-specific machinery that survives is the support function — the farthest point of a convex set along a direction — and for a core hull it is four lines:
/// Farthest point of the core along `dir`.
public func support(_ dir: Vec2) -> Vec2 {
var best = points[0]
var bestDot = best.dot(dir)
for p in points.dropFirst() {
let d = p.dot(dir)
if d > bestDot { bestDot = d; best = p }
}
return best
}
Mass, and one honest limitation
A dynamic body needs three numbers from its shape: mass, centre of mass, and rotational inertia. For a polygon those come from the standard signed-triangle decomposition, fanned from the first vertex, with the inertia shifted from that vertex to the centroid by the parallel-axis theorem at the end. For a compound, each part is computed independently and then moved to the shared centre of mass the same way:
/// Mass of a whole collider: parts summed, each part's inertia moved to the shared
/// centre of mass by the parallel-axis theorem.
public static func of(_ collider: Collider, density: Float) -> MassData {
let parts = collider.parts.map { part -> (MassData, Vec2) in
let m = of(part.shape, density: density)
return (m, part.local.apply(m.centroid))
}
let total = parts.reduce(0) { $0 + $1.0.mass }
guard total > 0 else { return MassData(mass: 0, centroid: .zero, inertia: 0) }
let centre = parts.reduce(Vec2.zero) { $0 + $1.1 * $1.0.mass } / total
let inertia = parts.reduce(Float(0)) { acc, part in
acc + part.0.inertia + part.0.mass * (part.1 - centre).lengthSquared
}
return MassData(mass: total, centroid: centre, inertia: inertia)
}
The tests for this are the boring, valuable kind: testMassOfABoxMatchesTheTextbook, testMassOfACircleAndACapsule, testCompoundMassCombinesParts. A closed-form quantity should be checked against the closed form, not against last week's output.
Then there is the limitation, which is written down in the source rather than discovered later:
/// Rotation is integrated about `transform.position`. Dynamic bodies should therefore be
/// built with their collider centred on the local origin (`ShapeFactory.box`,
/// `regularNGon`, `circle` already are); an off-centre `CompoundShape` will still work
/// but spins slightly unphysically.
A fully correct engine integrates rotation about the centre of mass and keeps a separate origin for the shape. This one integrates about the body's origin and compensates by shifting the inertia out to match:
// Inertia about the body origin, which is what rotation is integrated about.
inertia = data.inertia + data.mass * data.centroid.lengthSquared
invInertia = fixedRotation || inertia <= 0 ? 0 : 1 / inertia
So the resistance to spin is right, and the point it spins about is the origin rather than the centre of mass. For a crate, a bomb, a coin or a chunk of debris — all of which are built centred — the two are the same point and there is no error at all. For a deliberately lopsided compound there is a small one. Carrying a second frame through every impulse, every contact and every query to remove it would cost more than the artefact it removes, and the note in the source is the record of having weighed that rather than missed it.
Next
Two core hulls and a question: how far apart are they, and if they are not apart, how deep in? The next post is the distance half of that — GJK, which finds the gap between two convex point sets by never looking at the sets themselves, only at what they answer when asked for a support point.