Vector, Matrix and MathHelper numerics: interpolation, clamping and degenerate inputs
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. Compared with the decompiled XNA 4.0 assembly; the oracle comparisons and the genuine-runtime expectations in CNA's compatibility tests are CNA's recorded results, not re-run. The scalar-division and SmoothStep last-bit differences were not measured.
This page describes what CNA's vector, matrix and MathHelper functions compute in the cases a method name leaves open: amounts outside [0, 1], inverted ranges, NaN and signed-zero operands, zero-length vectors, singular matrices and degenerate camera inputs. It also compares each behaviour with the genuine XNA 4.0 implementation, because several functions with the same name compute differently in MathHelper and in the vector types. It is for gameplay and tools programmers who need predictable numbers, and for porters matching an XNA game's results. The bit-level precision story (which products are accumulated wider than float to match XNA's x87 arithmetic) is maintained on Math internals and summarised at the end.
Operation groups and what each promises
| Group | Members | Boundary behaviour at TARGET |
|---|---|---|
| Arithmetic | Add, Subtract, Multiply and Divide (component-wise and by scalar), Negate, the operators, Min, Max, Clamp | Plain IEEE arithmetic; no checks for division by zero. Clamp, Min and Max select operands exactly as XNA does, including inverted ranges and NaN (below). Scalar division divides each component, where XNA multiplies by one reciprocal (below). |
| Measurement | Length, LengthSquared, Distance, DistanceSquared, Dot; Vector3::Cross | Accumulated wider than float and rounded once, as XNA's x87 arithmetic does: every Dot, Vector3::Cross, all four Vector3 magnitudes, and Length and Distance of the other vector types. LengthSquared and DistanceSquared of Vector2 and Vector4 stay single-precision sums, as in XNA (see the precision section). |
| Interpolation | Lerp, SmoothStep, Barycentric, CatmullRom, Hermite | Lerp, Barycentric, CatmullRom and Hermite take the amount as given, so values outside [0, 1] extrapolate. SmoothStep clamps its amount to [0, 1] first. |
| Transforms | Transform by Matrix or Quaternion, TransformNormal, plus array and index-range forms | TransformNormal exists on Vector2 and Vector3 only and omits translation. The range forms throw System::ArgumentException when index + length passes the end of either array and System::IndexOutOfRangeException for a negative index with a positive length; an empty or negative length copies nothing (argument errors). |
MathHelper::Lerp and the vectors' Lerp use the form a + (b − a)·t, which is XNA's; it is cheap but does not guarantee Lerp(a, b, 1) == b exactly for every pair. Matrix::Lerp interpolates all sixteen fields independently. It is not a substitute for decomposing two transforms and interpolating the rotation with Quaternion::Slerp: halfway between two different rotation matrices it generally yields axes shorter than one unit (between the identity and a 90-degree turn, the rotated axes have length √0.5), so the result also scales the object.
Same name, different function
A reader will assume that MathHelper::X and Vector3::X compute the same thing per component. At TARGET several do not; most of those differences are XNA's own, and the rest are last-bit ones:
| Function | MathHelper (scalar) | Vector2/3/4 (per component) | XNA 4.0 |
|---|---|---|---|
Clamp(v, min, max) with min > max | caps at max, then floors at min: min wins; Clamp(5, 10, 1) is 10 | the same comparison order per component: min wins, the same components give 10 | min wins in both the scalar and the vector form |
Max/Min with a NaN operand or a tie | NaN when either operand is NaN: Max(NaN, 1) and Max(1, NaN) are both NaN; on a tie the second operand | the ternary a > b ? a : b (< for Min) per component: the second operand on a tie and whenever a NaN is involved, so Max(NaN, 1) gives 1 and Max(1, NaN) gives NaN | the same split: MathHelper.Max calls Math.Max, which returns NaN; the vector form is the same ternary |
SmoothStep | clamps, then evaluates Hermite(v1, 0, v2, 0, t) in double, returning v1 or v2 exactly when t is within 2−24 of 0 or 1 | clamps, then a float Hermite basis with no endpoint shortcut | clamps, then Lerp(v1, v2, t·t·(3 − 2t)) in both forms |
Hermite | computed in double, endpoint shortcuts as above (the comment: a large amount then yields infinity instead of NaN) | float, h1…h4 basis, no shortcut | float basis, no shortcut, in both forms |
CatmullRom | computed in double | delegates to MathHelper::CatmullRom per component | float expression |
| Division by a scalar | — | Divide(v, s) and v / s divide every component by s; Vector3::operator/= multiplies by 1/s instead | Divide(v, s) and v / s multiply by 1/s (also Matrix.Divide(m, s)) |
The scalar and vector forms agree for ordinary finite inputs except in the last bit, and where they disagree on NaN they disagree exactly as XNA's two forms do. Since the 0.1.0 release Clamp, Min and Max follow XNA's operand selection in every vector type (the release let max win an inverted vector clamp and let scalar Max drop a NaN); MathBoundsCompatibilityTests.cpp pins both forms against expectations CNA states it took from the genuine runtime. The last-bit differences are real, though: a component divided by s and a component multiplied by 1/s can differ by one unit in the last place, so v / s, v /= s and XNA's v / s do not always agree (the /= source comment says it matches Divide's "reciprocal form", but Vector3::Divide(Vector3, float) divides directly). Where bit-exact agreement with a recorded XNA run matters, write the reciprocal multiplication yourself (CNA-BUG-068).
Angles, constants and epsilons
MathHelper::Pi,TwoPi,PiOver2,PiOver4,E,Log2EandLog10Earestatic constexpr floatwith XNA's single-precision values (Piis 3.14159274f).ToRadiansis one single-precision multiplication by the single-precision constant 0.0174532924 andToDegreesmultiplies by180.0f / Pi; both forms were chosen by comparison with the genuine XNA runtime's answers (31 angles each way in CNA's matrix oracle).WrapAnglereturns an angle in the half-open range (−π, π]: π stays π and −π becomes π. It returns an angle already in range unchanged, otherwise takesstd::fmod(angle, TwoPi)and adds or subtracts oneTwoPi; XNA reaches the same range throughMath.IEEERemainder.MathHelper::MachineEpsilonFloatis not a constant: it is astatic const floatcomputed during dynamic initialisation by halving until 1 + e compares equal to 1, which gives 2−24 (about 5.96e-8) with ordinary float evaluation.WithinEpsilon(a, b)tests |a − b| < that value. A static initialiser in another translation unit that callsWithinEpsilonmay run before the epsilon is set and see 0, so every comparison would be false; call it only aftermainstarts. The geometry code uses XNA's literal constants instead:Matrix::Decompose1e-4 (below), the ray–box test 1e-6, the ray–plane, ray–frustum and frustum point tests 1e-5; see Math internals: other numeric details.WithinEpsilon,Clamp(intcs, …)andClosestMSAAPowerare FNA-internal helpers that CNA makes public (C++ has no assembly-internal visibility); XNA's publicMathHelperhas none of them.
Named constants and static initialisation order
The MachineEpsilonFloat caveat above applies to most of the module's named constants as well. Only Vector2's four (Zero, One, UnitX, UnitY) are declared constinit in Vector2.cpp, which is possible because Vector2(float, float) is the only constexpr constructor among the vector types; they are constant-initialised and safe to read at any time. Vector3's eleven constants (including Up and Forward), Vector4's six, Quaternion::Identity, Point::Zero, Rectangle::Empty and the 141 named Color values are plain static const objects built by out-of-line constructors, so each is dynamically initialised when its own translation unit's initialisers run, in an order C++ leaves unspecified across translation units. A namespace-scope or static-member initialiser in game code that copies one of them can run first and copy the still zero-initialised storage: Vector3::Up then reads as (0, 0, 0) and a named colour as transparent black, with no diagnostic. Matrix is different: there is no Matrix::Identity object, and Matrix::getIdentityProperty() returns a function-local static built on first call (Matrix.cpp), which is safe at any time.
Inside functions (constructors, Initialize, Update) every constant is ready, because the program has reached main. At namespace scope, spell the value or build it on first use (syntax-checked against the headers at c1c316b9, whose declarations used here are unchanged at TARGET; not run):
// Unordered with Vector3.cpp's initialisers: may copy (0, 0, 0).
static const Vector3 kWorldUp = Vector3::Up;
// Safe at any time: spelled literally, built on first call.
const Vector3& WorldUp()
{
static const Vector3 up(0.0f, 1.0f, 0.0f);
return up;
}
Degenerate inputs are quiet
Most of the module follows IEEE arithmetic instead of validating: a degenerate input produces infinities or NaNs, not an exception, exactly as XNA does for the same functions. The cases that reach games most often:
| Input | Result at TARGET |
|---|---|
Normalising a zero vector (Vector2/3/4::Normalize), a zero quaternion (Quaternion::Normalize, Inverse) or a plane with a zero normal (Plane::Normalize) | 1/√0 is +∞ and 0·∞ is NaN: every component becomes NaN. No fallback direction is substituted. |
Matrix::Invert of a singular matrix | The reciprocal of a zero determinant is infinite and the result fills with infinities and NaNs. Neither overload reports failure; test Determinant() first when the input may be degenerate. |
Matrix::CreateLookAt with coincident position and target, or an up vector parallel to the view direction | A zero vector is normalised: non-finite entries, no exception. |
Matrix::CreateBillboard with object and camera at the same point | Guarded as in XNA: below a squared distance of 0.0001 it uses the negated cameraForwardVector (or Vector3::Forward). An up vector parallel to the view direction is not guarded. |
Vector3::Reflect(v, normal) with a zero normal | Returns v unchanged; the normal is never normalised, so a non-unit normal scales the reflection. |
| Perspective builders | The only argument checks in the module's transforms: System::ArgumentOutOfRangeException for a field of view outside (0, π) in CreatePerspectiveFieldOfView (checked first, against MathHelper::Pi itself, so exactly π is refused as in XNA; the 0.1.0 release accepted it), then for a non-positive near or far distance and near ≥ far; the output matrix is left untouched. A NaN field of view passes those comparisons and yields NaN entries. The aspect ratio, the widths and heights and the orthographic builders are not validated at all: an aspect ratio of 0 gives an infinite M11, as in XNA (MatrixPerspectiveCompatibilityTest.DegenerateDimensionsRemainRawIeeeInputs). |
Matrix::Decompose with a mirrored, zero, tiny or sheared basis | XNA's algorithm (since the 0.1.0 release, which used FNA's). translation is row four and scale the three row lengths, both always written. The rows are ranked by length, an earlier axis winning a tie; a largest row shorter than 1e-4 is replaced by its unit axis, and when the smallest row is shorter than 1e-4 the middle row is replaced by a cross product, as XNA does. If the resulting basis has a negative determinant, the largest scale component and its row are negated, so a mirrored matrix decomposes with one negative scale on its longest axis: CreateScale(-2, 3, 4) composed with a rotation and a translation gives scale (2, 3, −4). The call returns false with rotation set to Quaternion::Identity when (det − 1)² exceeds 1e-4, which refuses shear and tiny non-zero axes (a scale of 1e-5 on one axis is refused, 1e-4 accepted; a shear of 0.2 in M12 is refused, 0.1 accepted). An exactly zero axis, and NaN or infinite input, return true with a NaN rotation. The fourth column is ignored. A negative scale component therefore signals a reflection, but not which axis was mirrored; use the sign of the 3×3 determinant for handedness. MatrixDecomposeCompatibilityTests.cpp pins each rule. |
The practical rule is to validate where bad values enter: user-authored axes, scales, planes, camera inputs and curve keys, and anything read from a file, before it reaches gameplay, rendering or a native API. A guarded move-towards step (syntax-checked against the headers at c1c316b9; the declarations it uses are unchanged at TARGET):
Vector2 MoveToward(Vector2 position, Vector2 target, float speed, float deltaSeconds)
{
Vector2 toTarget = target - position;
const float distance = toTarget.Length();
if (distance > 0.0001f) // never normalise a zero vector
{
toTarget.Normalize();
position = position + toTarget * std::min(speed * deltaSeconds, distance); // no overshoot
}
return position;
}
The private CheckForNaNs helpers on the vectors, Matrix and Quaternion (ported XNA debugging aids that throw System::NotFiniteNumberException; the matrix and quaternion versions only without NDEBUG) are never called, so nothing in CNA traps a NaN for you.
Where the arithmetic is wider than float
XNA 4.0 is a 32-bit assembly whose float arithmetic runs on the x87 unit, so an expression is accumulated at extended precision and rounded once when stored. CNA reproduces that, with double intermediates, wherever it measured the difference against the genuine runtime, and since the 0.1.0 release that covers most of the module:
- Products and transforms.
Matrix::Multiply,Matrix::Invert,Vector3::TransformandVector3::TransformNormalby a matrix, theDotproducts ofVector2,Vector3andVector4, andVector3::Cross, which also keeps XNA's subtraction order and with it the sign of a zero component (VectorProductCompatibilityTests.cpp). - Magnitudes.
Vector3::Length,LengthSquared,DistanceandDistanceSquaredaccumulate indoubleand round once; so doLengthofVector2,Vector4andQuaternionandDistanceofVector2andVector4, which is why a vector with a component of 1e20 reports a length of 1e20 instead of infinity.LengthSquaredandDistanceSquaredofVector2andVector4, andQuaternion::LengthSquared, stay single-precision sums, as in XNA; the squared length of that vector is infinite in every type (MagnitudeCompatibilityTests.cpp). - Normalisation.
Normalizeon the three vector types and onQuaternionrounds the length tofloatand then multiplies by its reciprocal indouble;Plane::Normalizedoes the same and leaves a near-unit plane unchanged (see Planes and half-spaces). - Quaternion inverse and division.
Quaternion::InverseandQuaternion::Dividekeep the squared norm, its reciprocal and the products indouble, so a representable result is not lost to an overflowing or underflowing intermediate (QuaternionRangeTests.cpp). - Geometry.
BoundingSphere::CreateFromPoints, the sphere and frustum containment tests, andBoundingBox::Contains(BoundingSphere)andIntersects(BoundingSphere); the details are on Planes, rays and bounding volumes.
The MathHelper interpolators use double for a different reason (their comments cite lost precision). Everything else is single precision, including Vector2::Transform, Vector4::Transform, Plane::DotCoordinate and the quaternion products. The measured table, the corpus sizes and the negative controls are on Math internals: which arithmetic is wider than float. For game code the consequence is small but real: results can differ in the last bit from earlier CNA releases, and an exact-boundary containment test can flip.
What the tests pin
At TARGET the math test directory holds 42 files with 957 statically counted GoogleTest definitions (TEST, TEST_F and TYPED_TEST, a typed test counted once; not executed for this page). 586 assertions use EXPECT_FLOAT_EQ (GoogleTest's four-ULP comparison); explicit EXPECT_NEAR tolerances are 1e-5 (79 uses), 1e-6 (65) and 1e-4 (8), and the per-file kEps is 1e-5 except in BoundingFrustumTests.cpp, where it is 1e-4. Three suites compare with answers recorded from the genuine XNA framework: MatrixOracleTests (444 cases), BoundingSphereOracleTests (445 point sets) and FrustumSeparationOracleTests (a frustum against spheres, boxes and frustums). The compatibility files added after the 0.1.0 release (MathBoundsCompatibilityTests, MagnitudeCompatibilityTests, VectorProductCompatibilityTests, VectorArrayRangeCompatibilityTests, MatrixDecomposeCompatibilityTests, MatrixPerspectiveCompatibilityTests, QuaternionRangeTests and others) compare bits exactly with expectations CNA states it measured on the genuine runtime; the colour and packed-vector rounding cases are checked from the graphics tree. The inverted-range Clamp, NaN and signed-zero operands, zero-vector normalisation, the field-of-view upper bound and Decompose's reflection and refusal rules are now pinned, and MatrixTest.SingularAffineInverseMatchesXnaInfinityAndNaNClassifications pins one singular inversion; the scalar-division rounding has no test against XNA, and the math regression-test recipe shows how to add one. When porting, compare the original program's observable results — projected points, collision answers, curve samples — rather than trusting a shared function name. Everything here was checked by reading TARGET at 3b43bd86 and the decompiled XNA 4.0 Microsoft.Xna.Framework assembly; nothing was run.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Math types: numerical behaviour · Tutorial 41: vector math · Tutorial 42: matrix operations
- Architecture
- Graphics architecture
- Maintainer workflow
- Add a regression test: the math case
- Tests and validation
- Test architecture