← Back

Oriented Bounding Boxes

How OBBs represent anisotropic Gaussian blobs, and how the Separating Axis Theorem checks their intersection — walking through a real Slang shader implementation.

1. The OBB Data Structure

An Oriented Bounding Box is defined by three fields in the Slang shader struct:

  • center — world-space position of the box's midpoint.
  • rotation — a 2×2 matrix whose rows are the local coordinate axes. Row 0 = local X axis (right), Row 1 = local Y axis (up). These are always unit-length and perpendicular.
  • scale — half-extents: the box extends ±scale.x along local X and ±scale.y along local Y.

A canonical box with corners (±1, ±1) is transformed into world space by scaling, rotating, and translating.

struct OBB
{
    float2 center;
    float2x2 rotation;  // rows are the local X and Y axes
    float2 scale;       // half-extents along local X and Y

    bool intersects(OBB other)
    {
        float2 canonicalPts[4] = float2[4](float2(-1,-1), float2(1,-1), float2(1,1), float2(-1,1));

        float2x2 invRotation = inverse(rotation);
        float2x2 otherInvRotation = inverse(other.rotation);
        float2 pts[4];
        for (int i = 0; i < 4; i++)
            pts[i] = center + float2(
                dot(invRotation[0], canonicalPts[i] * scale),
                dot(invRotation[1], canonicalPts[i] * scale));

        float2 otherPts[4];
        for (int i = 0; i < 4; i++)
            otherPts[i] = other.center + float2(
                dot(otherInvRotation[0], canonicalPts[i] * other.scale),
                dot(otherInvRotation[1], canonicalPts[i] * other.scale));

        return !(arePtsSeparatedAlongAxes(pts, otherPts, rotation) ||
                 arePtsSeparatedAlongAxes(pts, otherPts, other.rotation));
    }

    static bool arePtsSeparatedAlongAxes(float2[4] pts, float2[4] otherPts, float2x2 axes)
    {
        for (int i = 0; i < 2; i++)
        {
            float2 axis = axes[i];
            float2 proj      = float2(dot(pts[0], axis),      dot(pts[0], axis));
            float2 otherProj = float2(dot(otherPts[0], axis), dot(otherPts[0], axis));
            for (int j = 1; j < 4; j++)
            {
                proj.x      = min(proj.x,      dot(pts[j],      axis));
                proj.y      = max(proj.y,      dot(pts[j],      axis));
                otherProj.x = min(otherProj.x, dot(otherPts[j], axis));
                otherProj.y = max(otherProj.y, dot(otherPts[j], axis));
            }
            if (proj.y < otherProj.x || otherProj.y < proj.x)
                return true;
        }
        return false;
    }
};

Use the sliders below to explore how the three parameters shape the OBB. The dashed unit square in the center is the canonical box — notice how scale stretches it and rotation spins it.

Demo 1 — OBB builder. Drag sliders to adjust shape.

Rows of the rotation matrix ARE the local axes

2. The Rotation Matrix Convention

The Slang struct stores the rotation matrix with axes as rows — this is the key to understanding the vertex computation. Let's unpack why the code calls inverse(rotation).

In the Slang struct, rotation[i] refers to the i-th row. For angle θ:

This is the transpose of the standard rotation matrix (which stores axes as columns). Since it's orthonormal, inverse(rotation) = transpose(rotation), which gives back the standard rotation matrix.

Standard rotation (axes as columns):    Slang rotation (axes as rows):
[ cosθ  -sinθ ]                         [ cosθ   sinθ ]  ← rotation[0] = local X
[ sinθ   cosθ ]                         [-sinθ   cosθ ]  ← rotation[1] = local Y

inverse = itself transposed:            inverse(rotation) = standard rotation:
[ cosθ   sinθ ]                         [ cosθ  -sinθ ]
[-sinθ   cosθ ]                         [ sinθ   cosθ ]

Applying inverse(rotation) to a scaled canonical point gives the correct world-space offset. Here's a concrete example for θ = 30°, scale = (80, 40), canonical corner (−1, −1):

invRot[0] = (cos30°, −sin30°) = (0.866, −0.5)
invRot[1] = (sin30°,  cos30°) = (0.5,   0.866)

pt.x = dot(invRot[0], (−1,−1) * (80,40)) = dot((0.866,−0.5), (−80,−40))
     = 0.866 × (−80) + (−0.5) × (−40) = −69.3 + 20 = −49.3
pt.y = dot(invRot[1], (−1,−1) * (80,40)) = dot((0.5,0.866), (−80,−40))
     = 0.5 × (−80) + 0.866 × (−40) = −40 + (−34.6) = −74.6
vertex = center + (−49.3, −74.6)

The dot product formulation in Slang is equivalent to matrix-vector multiplication: dot(invRot[0], scaledPt) gives the x component, dot(invRot[1], scaledPt) gives the y component.

3. SAT for OBBs: Only 4 Axes

The Separating Axis Theorem states: two convex shapes are not intersecting if and only if there exists an axis along which their projections don't overlap. For general polygons you need to test all edge normals — M+N axes for an M-gon and N-gon. But for two OBBs you only need 4 axes total.

Why only 4? Each OBB has just 2 pairs of parallel edges, so only 2 unique edge normals. Those normals are exactly the rows of its rotation matrix. Test both OBBs' axes:

For each axis, project all 4 vertices of each OBB onto it and check for a gap. If any axis shows a gap, the boxes are separated. The Slang code:

static bool arePtsSeparatedAlongAxes(float2[4] pts, float2[4] otherPts, float2x2 axes)
{
    for (int i = 0; i < 2; i++)
    {
        float2 axis = axes[i];
        // proj.x = min projection of OBB A's vertices onto axis
        // proj.y = max projection of OBB A's vertices onto axis
        float2 proj      = float2(dot(pts[0], axis),      dot(pts[0], axis));
        float2 otherProj = float2(dot(otherPts[0], axis), dot(otherPts[0], axis));
        for (int j = 1; j < 4; j++)
        {
            proj.x      = min(proj.x,      dot(pts[j],      axis));
            proj.y      = max(proj.y,      dot(pts[j],      axis));
            otherProj.x = min(otherProj.x, dot(otherPts[j], axis));
            otherProj.y = max(otherProj.y, dot(otherPts[j], axis));
        }
        // Separated if A's max < B's min, OR B's max < A's min
        if (proj.y < otherProj.x || otherProj.y < proj.x)
            return true;
    }
    return false;
}

The demo below shows both OBBs and all four projection intervals. A red interval means that axis has no separating gap. A green gap means the boxes are separated along that axis (and therefore not intersecting at all).

SEPARATED

Demo 2 — 4-axis test panel. Drag either OBB to move it.

Axis A[0] (OBB A local X)
Axis A[1] (OBB A local Y)
Axis B[0] (OBB B local X)
Axis B[1] (OBB B local Y)
4. Live OBB-OBB Intersection

Drag either OBB to move it. Drag the circle handle on the right edge to rotate, and the square handle on the top-right corner to scale. When the boxes collide the Minimum Translation Vector (MTV) shows the shortest push to separate them.

SEPARATED

Demo 3 — Full interactive OBB intersection. Circle handle = rotate, square handle = scale.

5. OBBs and Gaussian Blobs

In 2D Gaussian Splatting, each splat is a 2D Gaussian distribution parameterised by a mean μ (center) and a 2×2 covariance matrix Σ. The covariance encodes both orientation and spread.

The eigendecomposition Σ = V · diag(λ₁, λ₂) · Vᵀ yields:

This means any 2D Gaussian can be exactly represented as an OBB. The iso-contour at level k is an axis-aligned ellipse with semi-axes k·σ_x, k·σ_y in the local frame — which, when rotated by V, becomes the OBB boundary at that coverage level.

// Decompose covariance Σ = [[a,b],[b,c]] into OBB
// Eigenvalues: λ = ((a+c) ± sqrt((a-c)² + 4b²)) / 2
// Eigenvector for λ₁: normalize((b, λ₁ - a))  [or (1,0) if b≈0]
// OBB.rotation[0] = eigenvector₁   (local X axis)
// OBB.rotation[1] = eigenvector₂   (local Y axis, perp to [0])
// OBB.scale = 3 * (sqrt(λ₁), sqrt(λ₂))

Adjust the Gaussian parameters below. The dashed green box is the OBB at the chosen coverage level.

Demo 4 — Gaussian → OBB. Each contour is a 1σ, 2σ, 3σ iso-ellipse.

OBB = 3σ bounding box | scale = (3·σ_x, 3·σ_y)