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
Vector3whose 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,Sphere3DandCapsule3Deach answerContains(point),ClosestPoint(point)andDistance(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.
AABB3DandSphere3Dboth provideIntersectsRay, returning the distance along the ray so you can sort hits without a second pass. - Fitting from points.
AABB3D.FromPointsandSphere3D.FromPointstake aReadOnlySpan<Vector3>and build the volume that encloses them, which is the usual way a bounding volume comes into existence. - Growing volumes in place.
Encapsulateexpands an AABB or sphere to include a point, andExpandinflates an AABB by a margin, which is how you build a bounding box in a loop. - Rotation without matrices.
OBB3Dcarries aQuaternionand exposesWorldToLocalandLocalToWorld, so an oriented containment test is one call rather than an inverse transform you wrote by hand. - Named extents.
Size2DandSize3DcarryArea,Volume,AspectRatio,Min,Max,IsSquareandIsCube, replacing the arithmetic that otherwise gets rewritten at each call site. - Implicit conversion to Unity types.
Size2Dconverts to and fromVector2,Size3Dto and fromVector3, andAABB3Dto and fromBounds, all implicitly. Integer vector conversions are explicit, because they truncate. - Ellipse evaluation.
Evaluatewalks the perimeter on a normalized0..1parameter andGetPointAtAnglesamples it by radians, withContainsfor 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.
| Type | Described by | Tightness | Notes |
|---|---|---|---|
AABB3D | Center + HalfExtents | Loose on anything rotated | The 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. |
OBB3D | Center + HalfExtents + Rotation | Tight on box-like objects at any angle | What 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. |
Sphere3D | Center + Radius | Tight on round things, loose on everything else | The 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. |
Capsule3D | PointA + PointB + Radius | Tight on characters and limbs | A 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));
Push an entity to the nearest legal spot
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 AABB3D | vs OBB3D | vs Sphere3D | vs Capsule3D | |
|---|---|---|---|---|
AABB3D | yes | via OBB3D | via Sphere3D | convert with ToAABB3D |
OBB3D | yes | yes | convert with ToAABB3D | convert with ToAABB3D |
Sphere3D | yes | convert with ToAABB3D | yes | via Capsule3D |
Capsule3D | convert with ToAABB3D | convert with ToAABB3D | yes | yes |
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
AABB3Dfirst and only upgrade when it is visibly wrong. An axis-aligned test is a handful of comparisons. Move toOBB3Dwhen 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
inwhere the signature offers it. EveryIntersectsandEncapsulateoverload taking another volume takes it byin, which avoids copying a struct that can be as large asOBB3D. Your own methods should follow the same pattern. - Never start an accumulation from
Empty.AABB3D.EmptyandSphere3D.Emptysit 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.
EncapsulateandExpandchange 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
Distanceover comparing to a computed magnitude. It measures from the surface, returns zero inside, and avoids the square root when the implementation can. - Use
Size3Din 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
Boundsat the boundary, not throughout.AABB3Dconverts 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.9becomes3. 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
AABB3Dfrom points every frame for a static prop is wasted work. Rebuild on move, not on query. - Use
Capsule3Dfor 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
Related
- 01 Scylla Core
- 31 Core Utilities – Math
- 19 Core Structures – Spatial Structures
- 38 Core Utilities – Debug Draw
- 34 Core Utilities – Unit
- 49 Core Utilities – 3D Shapes
- API reference:
Scylla.Core.Util.Geom.Size2D - API reference:
Scylla.Core.Util.Geom.Size3D - API reference:
Scylla.Core.Util.Geom.Ellipse - API reference:
Scylla.Core.Util.Geom.AABB3D - API reference:
Scylla.Core.Util.Geom.OBB3D - API reference:
Scylla.Core.Util.Geom.Sphere3D - API reference:
Scylla.Core.Util.Geom.Capsule3D