33 Core Utilities – Geometry

20 min read

The sizes, bounds and volumes you reach for when gameplay code needs to ask whether a thing is inside another thing, and how far away it is if not.

Summary

Scylla’s geometry utilities are seven small value types that give a name to shapes gameplay code keeps rebuilding out of loose floats and vectors. Two of them describe extent, Size2D and Size3D. One describes a planar oval, Ellipse. The other four are the volumes collision and query code actually reasons about: AABB3D for an axis-aligned box, OBB3D for a box that can rotate, Sphere3D for a ball, and Capsule3D for the pill shape a character controller lives in.

They exist because Unity gives you Bounds and not much else. Bounds is axis-aligned only, so the moment you need a rotated crate or a capsule trigger you are back to hand-rolled dot products. These types cover that gap with a consistent surface: every volume answers Contains, ClosestPoint and Distance, and every pairing that makes sense answers Intersects. Once you know one of them you know all four.

All seven are structs implementing IEquatable<T>, so they live on the stack, compare by value, and cost nothing to pass around in a query loop. That matters because the natural home for this code is a broad-phase check running over every entity in the scene. AABB3D converts implicitly to and from Bounds in both directions, so you can adopt it in one system without touching the ones that already speak Unity’s type.

These types describe shapes to test, one against another. Searching many of them at once, asking “which of my four thousand entities are near this point” rather than “are these two things touching”, is a job for a tree from Spatial Structures, and these types are what you feed it.

Use the geometry utilities for:

  • Trigger volumes, aggro radii and blast ranges that gameplay code owns rather than the physics engine.
  • Broad-phase culling before an expensive check: an AABB test to decide whether the precise one is worth running.
  • Fitting a bounding volume around a set of points, such as a squad, a room’s contents, or a procedurally placed prop cluster.
  • Passing extents around as one named value instead of a bare Vector3 whose meaning depends on the parameter name.
  • Placing things on an ellipse: a ring of enemies, an orbital path, a radar sweep.

Features

  • Four bounding volumes with one shared surface. AABB3D, OBB3D, Sphere3D and Capsule3D each answer Contains(point), ClosestPoint(point) and Distance(point). Learn the pattern once and it transfers.
  • Cross-type intersection where it is useful. A sphere tests against another sphere and against an AABB, a capsule against another capsule and against a sphere, an OBB against another OBB and against an AABB.
  • Ray tests on the round shapes. AABB3D and Sphere3D both provide IntersectsRay, returning the distance along the ray so you can sort hits without a second pass.
  • Fitting from points. AABB3D.FromPoints and Sphere3D.FromPoints take a ReadOnlySpan<Vector3> and build the volume that encloses them, which is the usual way a bounding volume comes into existence.
  • Growing volumes in place. Encapsulate expands an AABB or sphere to include a point, and Expand inflates an AABB by a margin, which is how you build a bounding box in a loop.
  • Rotation without matrices. OBB3D carries a Quaternion and exposes WorldToLocal and LocalToWorld, so an oriented containment test is one call rather than an inverse transform you wrote by hand.
  • Named extents. Size2D and Size3D carry Area, Volume, AspectRatio, Min, Max, IsSquare and IsCube, replacing the arithmetic that otherwise gets rewritten at each call site.
  • Implicit conversion to Unity types. Size2D converts to and from Vector2, Size3D to and from Vector3, and AABB3D to and from Bounds, all implicitly. Integer vector conversions are explicit, because they truncate.
  • Ellipse evaluation. Evaluate walks the perimeter on a normalized 0..1 parameter and GetPointAtAngle samples it by radians, with Contains for the inside test.
  • Struct semantics throughout. Every type is a value type implementing IEquatable<T> with == and != defined, so no allocation and no reference aliasing in a hot loop.

The types

The four volumes differ in what they can represent and what they cost to test. Pick the cheapest one that describes the shape honestly. A “tight” volume is one that hugs the object with little empty space; a loose volume produces false positives that a later, more expensive test has to reject.

TypeDescribed byTightnessNotes
AABB3DCenter + HalfExtentsLoose on anything rotatedThe cheapest test there is, and the one to reach for first. Axis-aligned means a containment check is three comparisons. Converts implicitly to and from Unity’s Bounds, so it drops into existing code. Use it as the broad phase even when the real shape is something else. Grows via Encapsulate and Expand, has GetCorner for the eight corners, and IntersectsRay for picking.
OBB3DCenter + HalfExtents + RotationTight on box-like objects at any angleWhat you want for a crate, a door panel, a vehicle chassis, anything box-shaped that does not sit on world axes. Costs a quaternion rotation per test, since Contains transforms the point into local space first. WorldToLocal and LocalToWorld are exposed for when you want that transform yourself. FromAABB builds one from an axis-aligned box, with or without a rotation.
Sphere3DCenter + RadiusTight on round things, loose on everything elseThe cheapest distance-based test, since containment is one squared-distance compare. The natural shape for an explosion radius, an aggro range, or a proximity trigger. Tests against other spheres and against an AABB. ToAABB3D gives you the enclosing box when you need to hand it to something that only speaks boxes.
Capsule3DPointA + PointB + RadiusTight on characters and limbsA segment with a radius, which is the shape a humanoid actually occupies and the reason Unity’s character controller uses one. Also right for a sword arc, a thick raycast, or a corridor volume. FromAxisAligned builds one from a center, an axis, a height and a radius when you would rather not compute the two endpoints.

Size2D and Size3D are not volumes and carry no position; they describe how big something is, not where it sits. That distinction from Vector2 and Vector3 matters in a signature: a method that takes a Size3D cannot be handed a position by mistake.

Ellipse is planar and carries a center plus two semi-axis radii. SemiMajorAxis and SemiMinorAxis return the larger and smaller of the two regardless of which axis they sit on, so eccentricity-style math does not need to branch.

Recipes

Each recipe answers one problem you’d actually run into, and none of them depend on each other.

Fit a bounding box around a squad

Problem. You have a group of units and you need one box around all of them, to frame the camera, to decide whether the group is visible, or to test whether an area-of-effect covers them.

Solution. FromPoints takes a span and returns the enclosing box in one pass. When the positions live in a list you already own, this costs no allocation.

/* Gather the squad's positions into a reusable buffer. The span overload means no
   array is allocated per frame, which matters when this runs on every selection change. */
Span<Vector3> positions = stackalloc Vector3[squad.Count];

for (var i = 0; i < squad.Count; i++)
{
    positions[i] = squad[i].transform.position;
}

/* One pass over the points produces the tightest axis-aligned box containing them. */
var squadBounds = AABB3D.FromPoints(positions);

/* Pad it so the camera does not frame units flush against the screen edge. */
squadBounds.Expand(2f);

/* Size is the full extent, not the half extent, which is what a camera fit wants. */
var framingDistance = squadBounds.Size.magnitude;

Build a bounding volume as things spawn

Problem. You are placing props procedurally into a room and you want the room’s bounds to grow to fit whatever ends up in it, without a second pass over the results.

Solution. Start from Empty and Encapsulate each item as it is placed.

/* Empty is a zero-size box at the origin, the right starting point for accumulation. */
var roomBounds = AABB3D.Empty;
var firstProp = true;

foreach (var spawnPoint in generator.PropPositions)
{
    var prop = SpawnProp(spawnPoint);

    /* Starting from Empty means the origin would be included in the box, dragging it
       toward zero. Seed from the first real point instead, then grow from there. */
    if (firstProp)
    {
        roomBounds = new AABB3D(prop.transform.position, Vector3.zero);
        firstProp = false;
    }
    else
    {
        roomBounds.Encapsulate(prop.transform.position);
    }
}

/* Encapsulate also takes another box, so a room can absorb a sub-room's bounds whole. */
roomBounds.Encapsulate(alcoveBounds);

Test an explosion against everything nearby

Problem. A grenade goes off and you need the list of things caught in the blast. You want the cheap check first so the expensive one runs on as few candidates as possible.

Solution. A sphere is the blast. Test each candidate’s box against it, which is the cross-type overload, and only then do the work that actually costs something.

var blast = new Sphere3D(detonationPoint, blastRadius);

foreach (var target in nearbyTargets)
{
    /* Sphere against AABB is the broad phase: cheap, and false positives are fine
       because the next check rejects them. */
    if (!blast.Intersects(target.Bounds))
    {
        continue;
    }

    /* Distance is measured from the sphere's surface, so a point inside returns zero
       rather than a negative number. Falloff can use it directly. */
    var distance = blast.Distance(target.transform.position);
    var falloff  = 1f - Mathf.Clamp01(distance / blastRadius);

    target.ApplyDamage(baseDamage * falloff);
}

Put a rotated hitbox on a crate

Problem. A crate sits at an angle and an axis-aligned box around it covers floor the crate does not occupy, so shots that visibly miss are registering as hits.

Solution. OBB3D carries the rotation, so containment is exact for a box at any orientation.

/* Half extents, so a 2x1x2 crate has half extents of (1, 0.5, 1). */
var crateBox = new OBB3D(
    crate.transform.position,
    new Vector3(1f, 0.5f, 1f),
    crate.transform.rotation);

/* Contains transforms the point into the box's local space, so the rotation is
   honoured without you writing the inverse transform. */
if (crateBox.Contains(projectile.transform.position))
{
    crate.Splinter();
}

/* When something else needs a plain box, ToAABB3D returns the axis-aligned box that
   encloses the rotated one. Looser, but it speaks Bounds. */
var broadPhase = crateBox.ToAABB3D();

Give a character a capsule that matches how it moves

Problem. You are doing your own movement or your own melee resolution and a sphere is too fat at the feet while a box catches corners that the character visibly slides past.

Solution. A capsule is a segment with a radius, which is the shape a standing humanoid actually occupies.

/* FromAxisAligned takes the total height including the caps, so a 1.8m character
   with a 0.3m radius is exactly that, with no endpoint arithmetic on your side. */
var body = Capsule3D.FromAxisAligned(
    center: player.transform.position,
    axis:   Vector3.up,
    height: 1.8f,
    radius: 0.3f);

/* Capsule against sphere covers the common case of a projectile or a pickup. */
if (body.Intersects(new Sphere3D(pickup.transform.position, 0.25f)))
{
    Collect(pickup);
}

/* Capsule against capsule is how two characters test against each other, and it is
   what makes shoulder-to-shoulder crowding behave instead of jittering. */
foreach (var other in nearbyCharacters)
{
    if (body.Intersects(other.BodyCapsule))
    {
        ApplySeparation(other);
    }
}

Place enemies on an ellipse

Problem. You want a boss arena where adds spawn around the rim, and a circle looks wrong because the arena is wider than it is deep.

Solution. Evaluate walks the perimeter on a 0..1 parameter, which divides evenly by however many you are spawning.

/* Two radii, so the ring matches the arena's proportions rather than forcing a circle. */
var arenaRim = new Ellipse(radiusX: 12f, radiusY: 7f, center: Vector2.zero);

for (var i = 0; i < addCount; i++)
{
    /* Evaluate takes a normalized parameter, so an even split around the rim is just
       the loop index over the count. */
    var t     = i / (float) addCount;
    var point = arenaRim.Evaluate(t);

    /* The ellipse is planar, so map its X and Y onto the ground plane. */
    SpawnAdd(new Vector3(point.x, 0f, point.y));
}

/* GetPointAtAngle samples by radians instead, which suits a sweep that follows a
   rotating turret rather than an even distribution. */
var sweepPoint = arenaRim.GetPointAtAngle(turret.CurrentAngleRadians);

/* Contains answers whether the player has stepped outside the arena floor. */
var playerFlat = new Vector2(player.position.x, player.position.z);

if (!arenaRim.Contains(playerFlat))
{
    ApplyOutOfBoundsDamage();
}

Pass extents around without losing what they mean

Problem. A method takes three Vector3 parameters and two of them are positions while one is a size. The compiler cannot help, and the bug surfaces as geometry in the wrong place.

Solution. Size3D is a distinct type, so the mistake becomes a compile error instead of a runtime oddity.

/* The signature now says which parameter is an extent and which is a position. */
public void BuildRoom(Vector3 origin, Size3D dimensions)
{
    /* Derived values come with the type rather than being recomputed at each site. */
    var floorArea = dimensions.Width * dimensions.Depth;
    var isCubic   = dimensions.IsCube;

    /* Implicit conversion to Vector3 means Unity APIs still take it directly. */
    transform.localScale = dimensions;
}

/* Cube builds a uniform extent without repeating the value three times. */
BuildRoom(Vector3.zero, Size3D.Cube(4f));

/* With* returns a modified copy, leaving the original alone. */
var taller = roomSize.WithHeight(roomSize.Height * 2f);

/* Lerp between two extents, which is how a room grows over time. */
var growing = Size3D.Lerp(startSize, endSize, growthCurve.Evaluate(t));

Sort ray hits without a second pass

Problem. A shot passes through several candidate volumes and you need the nearest, not just any of them.

Solution. IntersectsRay hands back the distance along the ray, so the comparison is available at the point of the test.

var shot     = new Ray(muzzle.position, muzzle.forward);
var nearest  = float.MaxValue;
Target hit   = null;

foreach (var candidate in targets)
{
    /* The out distance is measured along the ray from its origin, so comparing two
       hits needs no extra work. */
    if (!candidate.Bounds.IntersectsRay(shot, out var distance))
    {
        continue;
    }

    if (distance < nearest)
    {
        nearest = distance;
        hit     = candidate;
    }
}

hit?.TakeHit(shot.GetPoint(nearest));

Problem. Something has ended up inside geometry it should not be in, after a teleport, a spawn, or a physics nudge, and you want the closest point outside.

Solution. Every volume implements ClosestPoint, which returns the point on or inside the shape nearest the one you gave it.

/* ClosestPoint on a volume the entity is inside returns the entity's own position,
   since that point is already the nearest one within the shape. Test containment
   first when you specifically want to be pushed out. */
if (noGoZone.Contains(entity.position))
{
    /* Find the surface point, then step just past it along the outward direction. */
    var surface  = noGoZone.ClosestPoint(entity.position);
    var outward  = (surface - noGoZone.Center).normalized;

    entity.position = surface + outward * skinWidth;
}

/* Distance is the same measurement without the point, which suits a soft warning
   before the hard correction. */
var proximity = noGoZone.Distance(entity.position);

if (proximity < warningRange)
{
    ShowBoundaryWarning();
}

API Sketch

Everything lives in Scylla.Core.Util.Geom. The four volumes share a surface, so the sketch groups them by what they answer rather than repeating each type in full.

/* --- Extents. No position, just how big. --- */
struct Size2D : IEquatable<Size2D> {
    float Width, Height;
    float Area, Min, Max, AspectRatio;
    bool  IsEmpty, IsSquare;
    static readonly Size2D Empty, One;

    Size2D(float width, float height);
    Size2D(float size);
    Size2D(Vector2 size);
    Size2D(Vector2Int size);

    static Size2D Square(float size);
    static Size2D Lerp(in Size2D a, in Size2D b, float t);
    static Size2D Clamp(in Size2D value, in Size2D min, in Size2D max);
    Size2D WithWidth(float width);
    Size2D WithHeight(float height);

    /* Implicit both ways with Vector2; explicit both ways with Vector2Int. */
}

struct Size3D : IEquatable<Size3D> {
    float Width, Height, Depth;
    float Volume, Min, Max;
    bool  IsEmpty, IsCube;
    static readonly Size3D Empty, One;

    Size3D(float width, float height, float depth);
    Size3D(float size);
    Size3D(Vector3 size);
    Size3D(Vector3Int size);

    static Size3D Cube(float size);
    static Size3D Lerp(in Size3D a, in Size3D b, float t);
    static Size3D Clamp(in Size3D value, in Size3D min, in Size3D max);
    Size3D WithWidth(float width);
    Size3D WithHeight(float height);
    Size3D WithDepth(float depth);

    /* Implicit both ways with Vector3; explicit both ways with Vector3Int. */
}

/* --- Planar oval. --- */
struct Ellipse : IEquatable<Ellipse> {
    float   Area, SemiMajorAxis, SemiMinorAxis;
    bool    IsCircle;

    Ellipse(float radiusX, float radiusY);
    Ellipse(float radiusX, float radiusY, Vector2 center);
    Ellipse(float radius);
    Ellipse(float radius, Vector2 center);

    Vector2 Evaluate(float t);              // t normalized 0..1 around the perimeter
    Vector2 GetPointAtAngle(float radians);
    bool    Contains(Vector2 point);
}

/* --- Bounding volumes. --- */
struct AABB3D : IEquatable<AABB3D> {
    Vector3 Center, HalfExtents;
    Vector3 Min, Max, Size;
    float   Volume;
    static readonly AABB3D Empty;

    AABB3D(Vector3 center, Vector3 halfExtents);
    static AABB3D FromMinMax(Vector3 min, Vector3 max);
    static AABB3D FromCenterSize(Vector3 center, Vector3 size);
    static AABB3D FromPoints(ReadOnlySpan<Vector3> points);

    bool    Contains(Vector3 point);
    bool    Contains(in AABB3D other);
    bool    Intersects(in AABB3D other);
    bool    IntersectsRay(Ray ray, out float distance);
    Vector3 ClosestPoint(Vector3 point);
    float   Distance(Vector3 point);
    void    Encapsulate(Vector3 point);     // mutates in place
    void    Encapsulate(in AABB3D other);   // mutates in place
    void    Expand(float amount);           // mutates in place
    void    Expand(Vector3 amount);         // mutates in place
    Vector3 GetCorner(int index);           // 0..7

    /* Implicit both ways with UnityEngine.Bounds. */
}

struct OBB3D : IEquatable<OBB3D> {
    Vector3    Center, HalfExtents;
    Quaternion Rotation;
    Vector3    Size, AxisX, AxisY, AxisZ;
    float      Volume;

    OBB3D(Vector3 center, Vector3 halfExtents, Quaternion rotation);
    static OBB3D FromAABB(in AABB3D box);
    static OBB3D FromAABB(in AABB3D box, Quaternion rotation);

    Vector3 WorldToLocal(Vector3 worldPoint);
    Vector3 LocalToWorld(Vector3 localPoint);
    bool    Contains(Vector3 point);
    Vector3 ClosestPoint(Vector3 point);
    float   Distance(Vector3 point);
    Vector3 GetCorner(int index);
    AABB3D  ToAABB3D();
    bool    Intersects(in AABB3D box);
    bool    Intersects(in OBB3D other);
}

struct Sphere3D : IEquatable<Sphere3D> {
    Vector3 Center;
    float   Radius, Diameter, Volume, SurfaceArea;
    static readonly Sphere3D Empty, Unit;

    Sphere3D(Vector3 center, float radius);
    static Sphere3D FromPoints(ReadOnlySpan<Vector3> points);

    bool    Contains(Vector3 point);
    bool    Intersects(in Sphere3D other);
    bool    Intersects(in AABB3D box);
    bool    IntersectsRay(Ray ray, out float distance);
    Vector3 ClosestPoint(Vector3 point);
    float   Distance(Vector3 point);
    void    Encapsulate(Vector3 point);     // mutates in place
    AABB3D  ToAABB3D();
}

struct Capsule3D : IEquatable<Capsule3D> {
    Vector3 PointA, PointB;
    float   Radius, Height;
    Vector3 Center;

    Capsule3D(Vector3 pointA, Vector3 pointB, float radius);
    static Capsule3D FromAxisAligned(Vector3 center, Vector3 axis, float height, float radius);

    bool    Contains(Vector3 point);
    bool    Intersects(in Capsule3D other);
    bool    Intersects(in Sphere3D sphere);
    Vector3 ClosestPoint(Vector3 point);
    float   Distance(Vector3 point);
    AABB3D  ToAABB3D();
}

See the API reference for full signatures, remarks, and exception contracts on every member.

What tests against what

Not every pairing exists, because not every pairing is worth the code. This table is the complete set of Intersects overloads, so you can see at a glance whether a check is one call or needs converting first.

vs AABB3Dvs OBB3Dvs Sphere3Dvs Capsule3D
AABB3Dyesvia OBB3Dvia Sphere3Dconvert with ToAABB3D
OBB3Dyesyesconvert with ToAABB3Dconvert with ToAABB3D
Sphere3Dyesconvert with ToAABB3Dyesvia Capsule3D
Capsule3Dconvert with ToAABB3Dconvert with ToAABB3Dyesyes

Where the table says “via”, the overload exists on the other type: call sphere.Intersects(box) rather than box.Intersects(sphere). Where it says “convert”, use ToAABB3D on the tighter shape and accept the looser test, which is usually fine as a broad phase feeding something more precise.

Ray tests exist on AABB3D and Sphere3D only. For a rotated box, put the ray in local space with OBB3D.WorldToLocal and test the equivalent axis-aligned box.

Best Practices

  • Reach for AABB3D first and only upgrade when it is visibly wrong. An axis-aligned test is a handful of comparisons. Move to OBB3D when players notice hits landing on empty air beside a rotated object, not before.
  • Use the volumes as a broad phase, not as your physics. These are exact for the shapes they describe, but they are not a solver. They tell you what is touching; they do not resolve penetration, apply impulses, or handle continuous collision at speed.
  • Pass by in where the signature offers it. Every Intersects and Encapsulate overload taking another volume takes it by in, which avoids copying a struct that can be as large as OBB3D. Your own methods should follow the same pattern.
  • Never start an accumulation from Empty. AABB3D.Empty and Sphere3D.Empty sit at the world origin, so encapsulating into them drags the result toward zero. Seed from the first real element, then grow.
  • Remember which methods mutate. Encapsulate and Expand change the value in place and return nothing; everything else returns a new value. That is the one inconsistency in the surface, and it is deliberate, since growing a box in a loop should not allocate a copy per iteration.
  • Prefer Distance over comparing to a computed magnitude. It measures from the surface, returns zero inside, and avoids the square root when the implementation can.
  • Use Size3D in signatures where an extent could be mistaken for a position. The type is the documentation. A method taking (Vector3, Vector3, Vector3) invites the bug that (Vector3, Size3D, Quaternion) makes impossible.
  • Convert to Bounds at the boundary, not throughout. AABB3D converts implicitly, so a system can speak the Scylla type internally and hand Unity what it expects only where the two meet.
  • Round before converting a size to an integer vector. The explicit conversion truncates, so 3.9 becomes 3. If you are sizing a grid, that off-by-one is a missing row.
  • Cache the volume when the object has not moved. Building an AABB3D from points every frame for a static prop is wasted work. Rebuild on move, not on query.
  • Use Capsule3D for anything that stands up. Characters, pillars, tree trunks and thrown poles are all better served by a capsule than a box or a sphere, and the capsule-to-capsule test keeps crowds from jittering.
  • Do not use these for spatial lookup at scale. Testing one volume against four thousand others is a linear scan. That is what Spatial Structures exists for; feed it these types and let it do the culling.

Pitfalls