Planes, rays and bounding volumes: exact containment and intersection semantics
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page); oracle-compared (recorded by CNA, not re-run here). Claims on this page were checked by reading the CNA source at commit 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Worked values are CNA's own test expectations or were derived by hand from the code. XNA behaviour read in the decompiled XNA 4.0 framework assembly; the oracle comparisons (BoundingSphere::CreateFromPoints, the frustum separation corpus, the frustum ray cases) are CNA's recorded results, not re-run here.
Plane, Ray, BoundingBox, BoundingSphere and BoundingFrustum answer the geometric questions of culling, picking and collision. Their XNA names suggest one uniform quality level; at TARGET each function has its own boundary rules and tolerances, and nearly all of them reproduce the genuine XNA 4.0 implementation, including its convex frustum intersection and its ray clipping. This page gives the exact semantics per type — half-space conventions, corner order, what "touching" means, which tolerance applies — and marks the two places where CNA and XNA 4.0 still disagree, with a workaround. It is for gameplay programmers writing collision and picking code and for porters whose results must match XNA. Short member tables are on Math types: bounding types; Tutorial 44 and Tutorial 74 are the hands-on introductions.
Planes and half-spaces
A Plane stores a Normal and a scalar D and describes the set N·x + D = 0, so D is the negated distance of the plane from the origin along its normal: the plane y = 1 is Plane(Vector3(0, 1, 0), -1). Plane.cpp provides four constructors. Only the three-point constructor normalises: it takes N = normalize(Cross(b − a, c − a)) and D = −N·a, so the normal points to the side from which a, b, c appear counter-clockwise in a right-handed frame. Plane(normal, d), Plane(a, b, c, d) and Plane(Vector4) store what they are given; call Normalize() before using DotCoordinate as a distance.
DotCoordinate(p)= N·p + D is the signed distance of a point (for a unit normal) and is the classification primitive: positive means on the side the normal points to.DotNormal(v)= N·v ignores D and tests a direction, for example whether a ray is heading towards the plane.Dot(Vector4)= N·(x, y, z) + D·w covers both: w = 1 for points, w = 0 for directions.Normalizefollows XNA's arithmetic: the squared length of the normal is accumulated wider thanfloat, its square root is rounded tofloatbefore the reciprocal is taken, and a plane whose squared normal length lies within 2−23 (about 1.19e-7) of 1 is returned unchanged.Matrix::CreateReflectionandMatrix::CreateShadownormalise their plane argument the same way (PlaneNormalizationCompatibilityTests.cpp).
PlaneIntersectionType::Front means the whole volume lies on the positive side (the side the normal points to), Back the negative side and Intersecting both. BoundingSphere::Intersects(Plane) answers Front when the centre's distance exceeds the radius; BoundingBox::Intersects(Plane) tests the box's most negative and most positive vertex against the normal; both agree with XNA 4.0. The private helper Plane::IntersectsPoint, which only BoundingFrustum uses, returns Front for a positive distance, Back for a negative one and Intersecting for exactly zero; XNA has no such helper, and that zero case is what makes BoundingFrustum::Intersects(Plane) differ (below). The Doxygen comments in PlaneIntersectionType.hpp state the opposite meanings (Front "in the negative half-space"); trust the behaviour, not those two comments (CNA-BUG-025).
Transforming a plane
Planes transform with the inverse transpose of the point transform: if points move by M, the plane (N, D) as a row vector is multiplied by (M−1)T. CNA's Plane::Transform(plane, matrix) inverts M and transposes the inverse in place with Matrix::Transpose(transformedMatrix, transformedMatrix). That output-reference overload reads all sixteen source fields before it writes (see output-reference overloads), so the call yields the true inverse transpose and the result matches XNA 4.0, which inverts M and reads the rows of the inverse directly. The plane y = 2, Plane(Vector3::Up, -2), moved by CreateTranslation(0, 5, 0) becomes y = 7, Plane(Vector3::Up, -7), through the value form, the output-reference form and an output that aliases the input: MatrixTransposeCompatibilityTest.PlaneTransformRetainsTranslatedNonzeroOffset pins exactly that case, and AliasedOutputPreservesBitsAndIsAnInvolution pins the aliased transpose bit for bit.
Since the 0.1.0 release this overload is correct. The release's aliased transpose symmetrised the inverse instead of transposing it, so a translated plane came out tilted and a rotated one could end up facing the wrong way; code that worked around it by transposing into a separate object computes the same result and can stay as it is. The matrix overload does not renormalise: under a transform that scales, the resulting normal is no longer unit length, so call Normalize() before reading distances. Plane::Transform(plane, quaternion) rotates the normal with Vector3::Transform and keeps D, which is correct for a normalised plane rotated about the origin.
Rays: one type, four intersection rules
A Ray is a Position and a Direction; every intersection returns std::optional<float> (XNA's float?), empty for no hit. The box, plane and frustum tests return the ray parameter t, measured in multiples of Direction; the sphere test computes the distance along the ray as Dot(Direction, centre − origin), which is a distance only for a unit direction. XNA documents Direction as a unit vector, and CNA does not normalise it, so normalise picking directions before querying several shapes. The four rules, read in Ray.cpp and BoundingFrustum.cpp and compared with XNA 4.0:
| Target | CNA at TARGET | XNA 4.0 |
|---|---|---|
BoundingBox | Clips one interval that starts as [0, float max]. An axis whose direction component has |d| < 1e-6 counts as parallel and rejects the ray only when the origin lies outside that slab; every other axis multiplies the slab bounds by the reciprocal of its component and narrows the interval, and an empty interval is a miss. The answer is the interval's start, so an origin inside the box returns 0 (also for a stationary ray with a zero direction) and a box wholly behind the origin is empty. | The same rule and constants. |
BoundingSphere | An origin strictly inside (squared distance < r²) returns 0; otherwise the ray must point towards the centre, and the entry distance is d − √(r² + d² − L²). | Same formula, but the inside test is <=: an origin exactly on the surface returns 0. In CNA a ray starting on the surface and pointing outward returns empty (CNA-BUG-069). |
Plane | |Direction·N| < 1e-5 counts as parallel (empty). A hit behind the origin is empty, except within 1e-5 behind it, which returns 0. | Identical constants and rule. |
BoundingFrustum | An origin that Contains(Position) accepts (inside, or no more than 1e-5 outside every plane) returns 0. Otherwise the ray is clipped against the six planes: a plane nearly parallel to the ray, |N·d| < 1e-5, rejects it only when the origin is on the plane's outer side and is skipped otherwise; the remaining planes narrow an entry–exit interval, and an empty interval is a miss. The entry parameter is returned, or the exit parameter when the entry is negative, provided it is not negative. Nothing throws. | The same algorithm and constants. |
The frustum row is the one that changed most since the 0.1.0 release, which classified only the ray origin: a ray from outside that pointed into the frustum was reported as a miss, and an origin exactly on a plane threw System::NotImplementedException. At TARGET BoundingFrustum::Intersects(Ray) and Ray::Intersects(BoundingFrustum) clip the ray. For the camera frustum CreateLookAt((0, 0, 5), Zero, Up) * CreateOrthographic(2, 2, 1, 5), whose near plane lies at z = 4, a ray from (0, 0, 7) along −Z enters at t = 3; BoundingFrustumTest.IntersectsRayUsesTransformedAndCameraFrustumPlanes asserts that answer through both frustum overloads and through Ray::Intersects, and IntersectsRayClipsEveryPlaneWithXnaBoundaryRules covers 22 cases (entry through each of the six planes, boundary and stationary origins, the containment tolerance and the parallel threshold) that CNA records as passing against the genuine XNA 4.0 assembly as well. A nearest-hit picking loop over boxes (syntax-checked against the headers at c1c316b9; the declarations it uses are unchanged at TARGET):
const SceneObject* nearest = nullptr;
float nearestDistance = std::numeric_limits<float>::max();
for (const SceneObject& candidate : candidates)
{
if (const std::optional<float> hit = pickRay.Intersects(candidate.bounds);
hit && *hit < nearestDistance)
{
nearestDistance = *hit;
nearest = &candidate;
}
}
Build pickRay from two Viewport::Unproject calls at depth 0 and 1 and a normalised difference, as in Math types: code examples. Where a ray may start exactly on a sphere's surface and the XNA answer 0 matters, test Vector3::DistanceSquared(origin, centre) <= r * r first and treat it as a hit at 0.
BoundingBox
BoundingBox.cpp stores Min and Max and trusts them: nothing checks that Min ≤ Max.
- Corner order.
GetCorners()returns XNA's fixed order, the Max.Z face first, each face wound top-left, top-right, bottom-right, bottom-left: 0 = (Min.X, Max.Y, Max.Z), 1 = (Max.X, Max.Y, Max.Z), 2 = (Max.X, Min.Y, Max.Z), 3 = (Min.X, Min.Y, Max.Z), 4 = (Min.X, Max.Y, Min.Z), 5 = (Max.X, Max.Y, Min.Z), 6 = (Max.X, Min.Y, Min.Z), 7 = (Min.X, Min.Y, Min.Z). SoMaxis corner 1 andMincorner 7, not 0 and 7. No test asserts this order (GetCornersContainsMinAndMaxonly checks that both appear).GetCorners(std::vector<Vector3>&)fills an existing vector and throwsSystem::ArgumentOutOfRangeExceptionbelow eight elements;BoundingBox::CornerCountis 8. - Touching counts.
Contains(point)is inclusive on every face, andIntersects(BoundingBox)uses inclusive comparisons, so two boxes that share only a face intersect. That is the opposite ofRectangle(see Rectangle edge ownership). Both match XNA. - Box and sphere.
Contains(BoundingSphere)returns Contains when the sphere fits inside every face with its radius, otherwise accumulates the squared distance from the centre to the box (indouble) and answers Intersects or Disjoint;Intersects(BoundingSphere)is the same distance test. The answers agree with XNA's clamp-and-distance formulation for ordinary inputs. - Box and frustum.
Intersects(BoundingFrustum)delegates to the frustum's convex test (below).Contains(BoundingFrustum)follows XNA: Disjoint when the frustum does not intersect the box, Intersects as soon as one of the eight frustum corners lies outside the box, Contains otherwise. - Construction.
CreateFromPointstakes astd::vector<Vector3>and throwsSystem::ArgumentExceptionfor an empty one;CreateFromSphereandCreateMergedare exact min/max operations.
BoundingSphere
- The constructor
BoundingSphere(center, radius)refuses a negative radius withSystem::ArgumentException, as XNA's does; zero, negative zero, NaN and infinity are stored as given (GeometryValuesCompatibilityTest.SphereRejectsNegativeRadiusAndPreservesOtherValues). The default constructor gives radius 0, and the publicRadiusfield can still be assigned anything. CreateFromPointsreproduces XNA's own two-pass algorithm: the extreme points on each axis, the widest axis chosen by the distance between its extremes (the later axis wins a tie), a seed sphere of half that distance around their midpoint, and one growth pass in which each outside point moves the radius to the mean of the old radius and its distance. It is not the minimal enclosing sphere and depends on point order. Every intermediate is evaluated wider thanfloatand stored asfloat, andBoundingSphereOracleTestscompares the result bit for bit with 445 recorded point sets from the genuine XNA runtime; details on Math internals: BoundingSphere::CreateFromPoints. An empty list throwsSystem::ArgumentException, the same type as the box's.CreateFromFrustumruns it over the eight frustum corners.CreateFromBoundingBoxgives the circumscribed sphere: centre (Min + Max)/2 and radius the distance from that centre toMax. XNA computes the same sphere asLerp(Min, Max, 0.5)andDistance(Min, Max) · 0.5, so the two can differ in the last bit; no oracle covers it.Transform(matrix)transforms the centre withVector3::Transformand multiplies the radius by the square root of the largest squared length of the matrix's first three rows — XNA's formula. UnderCreateScale(2, 3, 4)a sphere of radius 2 gets radius 8. The result encloses the transformed ellipsoid, which is safe for broad-phase culling but loose; for a tight bound transform the source points and rebuild, or use an oriented box of your own.Contains(Vector3)returns Contains only for a point strictly inside and Disjoint otherwise, a point exactly on the surface included; it never answers Intersects, as in XNA. The value form compares a squared distance accumulated wider thanfloatwith r² computed indouble; the output-reference form first stores the squared distance as afloat, as XNA's does, so for a point within one rounding step of the surface the two overloads can disagree exactly as XNA's do (GeometryContainmentCompatibilityTest.SpherePointOverloadsRetainTheirMeasuredStorageBoundary). A NaN distance is Disjoint.Contains(BoundingSphere)compares the distance between the centres (not its square) with the signed radii, indouble: Disjoint unless R + r ≥ d, Contains when R − r ≥ d, otherwise Intersects. A larger sphere around the same centre is therefore Intersects:BoundingSphere(Vector3::Zero, 1).Contains(BoundingSphere(Vector3::Zero, 5))gives Intersects, as XNA does (SphereContainmentUsesSignedRadiusDifference).Intersects(BoundingSphere)is true only when (R + r)² > d², a strict comparison evaluated wider thanfloat: two spheres that only touch from outside do not intersect, and a NaN radius never intersects (SphereIntersectionExcludesExternalTangency).Intersects(BoundingBox)delegates to the box.Contains(BoundingBox),Contains(BoundingFrustum)answer Disjoint when the volume does not intersect the sphere, Intersects when any of its eight corners lies farther from the centre than the radius, and Contains otherwise. A corner exactly on the surface counts as inside here, unlikeContains(Vector3)(VolumeCornersRemainInclusiveDespiteStrictPointApi); XNA draws the same distinction.
BoundingFrustum
BoundingFrustum.cpp derives six normalised, outward-facing planes from its matrix in the order Near, Far, Left, Right, Top, Bottom (XNA's extraction; the derivation and the [0, 1] depth volume it implies are on Coordinate conventions: depth), and eight corners by intersecting three planes at a time. The corner order matches XNA's: the near face first — near-left-top, near-right-top, near-right-bottom, near-left-bottom — then the far face in the same winding, topologically the same order as the box's. CNA computes each corner with the three-plane formula; XNA intersects a plane with the line where two others meet, so the corners can differ in the last bit. Because the planes point outward, a Front classification against any one plane means outside.
Contains(Vector3)evaluates all six planes, accumulating each distance wider thanfloat, and answers Disjoint only when a distance exceeds XNA's tolerance of 1e-5; otherwise it answers Contains. Points on the boundary or up to 1e-5 outside it are inside, and a point is never Intersects (GeometryContainmentCompatibilityTest.FrustumPointChecksAllPlanesAndXnaTolerance,BoundingFrustumTest.OrthographicPointContainmentMatchesXnaFarPlaneTolerance).Contains(BoundingBox),Contains(BoundingSphere)classify the volume against each plane: any Front means Disjoint, any Intersecting means Intersects, otherwise Contains (the sphere form evaluates each plane distance indouble, as XNA's inlined expression does). This is XNA's rule and it is conservative: a volume beside an edge or corner of the frustum can straddle two side planes' extensions without touching the frustum and is then reported as Intersects.Intersects(BoundingBox),Intersects(BoundingSphere),Intersects(BoundingFrustum)run XNA's GJK convex-separation query (ported into the internalGjk.hpp, with XNA's progress and termination thresholds), not the plane rule, so in the edge and corner cases aboveContainsanswers Intersects whileIntersectsanswers false, exactly as in XNA.GeometryContainmentCompatibilityTest.FrustumIntersectionRequiresActualConvexSeparationshows the split for a sphere of radius 1 at (1.8, 1.8, 0.5) beside the identity-matrix frustum, andFrustumSeparationOracleTestscompares spheres, boxes and frustums with a corpus CNA recorded from the genuine XNA runtime (tests/reference/xna40/framework/frustum-separation-oracle.txt).BoundingBox::Intersects(frustum)andBoundingSphere::Intersects(frustum)delegate here.Intersects(Plane)classifies the eight corners withPlane::IntersectsPoint; a corner exactly on the plane counts as Intersecting, so a frustum touching a plane from behind is Intersecting where XNA, which counts a zero distance as Back, answers Back (CNA-BUG-071). Where that boundary matters, classify the corners yourself withDotCoordinateand treat a zero distance as Back.Contains(BoundingFrustum)answers Disjoint when the two frustums do not intersect, Intersects when any corner of the other frustum lies outside this one (by more than the point tolerance), and Contains otherwise. A frustum contains itself and every frustum built from the same matrix; there is no pointer-identity shortcut (BoundingFrustumTest.ContainsSameFrustumReturnContains).GetCorners(std::vector<Vector3>&)throwsSystem::ArgumentOutOfRangeExceptionbelow eight elements;PlaneCountis private. Equality andGetHashCodeuse only the matrix.
Containment and intersection answers at a glance
| Query | Matches XNA 4.0? | Where CNA differs |
|---|---|---|
Box: Contains point / box / sphere, Intersects box / sphere / plane | yes | — |
BoundingBox::Contains(BoundingFrustum) | yes | — |
BoundingSphere::Contains(Vector3) | yes | — (a surface point is Disjoint; the value and output forms round as XNA's do) |
BoundingSphere::Contains sphere / box / frustum | yes | — |
Sphere: Intersects sphere / box / plane | yes | — (spheres that only touch do not intersect) |
BoundingFrustum::Contains(Vector3) | yes | — (1e-5 tolerance) |
BoundingFrustum::Contains box / sphere / frustum | yes | — (plane rule for box and sphere) |
BoundingFrustum::Intersects box / sphere / frustum | yes | — (GJK) |
BoundingFrustum::Intersects(Plane) | almost | a corner exactly on the plane counts as Intersecting |
BoundingFrustum::Intersects(Ray), Ray::Intersects(BoundingFrustum) | yes | — |
Ray::Intersects(BoundingBox) | yes | — |
Ray::Intersects(BoundingSphere) | almost | an origin exactly on the surface, pointing outward, is a miss |
Ray::Intersects(Plane) | yes | — |
The two remaining differences are recorded on Known Issues (CNA-BUG-069, CNA-BUG-071), as is the swapped Doxygen of PlaneIntersectionType. The argument exceptions are listed on Math value types: argument errors.
Evidence
Checked by reading Plane.cpp, Ray.cpp, BoundingBox.cpp, BoundingSphere.cpp, BoundingFrustum.cpp, Gjk.hpp, Vector3Arithmetic.hpp and their tests at 3b43bd86; nothing was built or run, and the worked values are CNA's own test expectations or were derived by hand from the code. The XNA 4.0 behaviour was read in the decompiled genuine Microsoft.Xna.Framework assembly (BoundingBox, BoundingSphere, BoundingFrustum, Plane, Ray). Test coverage: BoundingBoxTests, BoundingSphereTests, BoundingFrustumTests, PlaneTests and RayTests are mostly analytic unit tests; GeometryContainmentCompatibilityTests, GeometryValuesCompatibilityTests, PlaneNormalizationCompatibilityTests and MatrixTransposeCompatibilityTests carry expectations CNA measured on the genuine runtime; the oracle suites are BoundingSphereOracleTests and FrustumSeparationOracleTests. The disjoint-frustum cases in BoundingBoxTests and BoundingSphereTests still assert only "not Contains"; the Disjoint answers are pinned by GeometryContainmentCompatibilityTest.FrustumContainmentIsGeometricNotPointerIdentity. RayTests.cpp still carries a stale comment saying its frustum case is omitted because the function is unimplemented; the ray–frustum cases live in BoundingFrustumTests.cpp. The maintainer failure map is on Math internals: failure map.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Maintainer workflow
- Add a regression test: the math case
- Tests and validation
- Test architecture