Vector, Matrix and MathHelper numerics: interpolation, clamping and degenerate inputs

CNA snapshot 009d40f5  ·  Deep Dives › Framework core  ·  source links pinned to 009d40f5

✓

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 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read at 009d40f5 and compared with the decompiled XNA 4.0 assembly; the oracle comparisons are CNA's recorded corpora, 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

GroupMembersBoundary behaviour at TARGET
ArithmeticAdd, Subtract, Multiply and Divide (component-wise and by scalar), Negate, the operators, Min, Max, ClampPlain IEEE arithmetic; no checks for division by zero. Clamp, Min and Max differ from XNA for inverted ranges and NaN (below). Scalar division divides each component, where XNA multiplies by one reciprocal (below).
MeasurementLength, LengthSquared, Distance, DistanceSquared, Dot; Vector3::CrossSingle-precision sums. Vector3's length and distance are summed in float, although CNA's own measurement of XNA shows a wider accumulation there (last-bit differences; see the precision section).
InterpolationLerp, SmoothStep, Barycentric, CatmullRom, HermiteLerp, Barycentric, CatmullRom and Hermite take the amount as given, so values outside [0, 1] extrapolate. SmoothStep clamps its amount to [0, 1] first.
TransformsTransform by Matrix or Quaternion, TransformNormal, plus array and index-range formsTransformNormal exists on Vector2 and Vector3 only and omits translation. The range forms throw std::out_of_range for negative indices or lengths and ranges past either array.

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, and some differ from XNA as well:

FunctionMathHelper (scalar)Vector2/3/4 (per component)XNA 4.0
Clamp(v, min, max) with min > maxtests max, then min: min wins; Clamp(5, 10, 1) is 10std::min(std::max(v, min), max): max wins; the same components give 1min wins in both the scalar and the vector form
Max/Min with a NaN operandternary a > b ? a : b: Max(NaN, 1) is 1std::max/std::min: the first operand unless the second compares larger (smaller)MathHelper.Max calls Math.Max, which returns NaN; the vector form is a ternary with XNA's own operand order
SmoothStepclamps, then evaluates Hermite(v1, 0, v2, 0, t) in double, returning v1 or v2 exactly when t is within 2−24 of 0 or 1clamps, then a float Hermite basis with no endpoint shortcutclamps, then Lerp(v1, v2, t·t·(3 − 2t)) in both forms
Hermitecomputed in double, endpoint shortcuts as above (the comment: a large amount then yields infinity instead of NaN)float, h1…h4 basis, no shortcutfloat basis, no shortcut, in both forms
CatmullRomcomputed in doubledelegates to MathHelper::CatmullRom per componentfloat expression
Division by a scalar—Divide(v, s) and v / s divide every component by s; Vector3::operator/= multiplies by 1/s insteadDivide(v, s) and v / s multiply by 1/s (also Matrix.Divide(m, s))

The algebra agrees everywhere in that table except Clamp with an inverted range and the NaN cases, so for ordinary finite inputs the differences are last-bit ones. They 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. The inverted-range Clamp and the NaN selection are recorded on Known Issues; order your bounds before clamping, or clamp per component with MathHelper::Clamp, whose rule matches XNA.

Angles, constants and epsilons

  • MathHelper::Pi, TwoPi, PiOver2, PiOver4, E, Log2E and Log10E are static constexpr float with XNA's single-precision values (Pi is 3.14159274f).
  • ToRadians is one single-precision multiplication by the single-precision constant 0.0174532924 and ToDegrees multiplies by 180.0f / Pi; both forms were chosen by comparison with the genuine XNA runtime's answers (31 angles each way in CNA's matrix oracle).
  • WrapAngle returns an angle in the half-open range (−π, π]: π stays π and −π becomes π. It returns an angle already in range unchanged, otherwise takes std::fmod(angle, TwoPi) and adds or subtracts one TwoPi; XNA reaches the same range through Math.IEEERemainder.
  • MathHelper::MachineEpsilonFloat is not a constant: it is a static const float computed 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 calls WithinEpsilon may run before the epsilon is set and see 0, so every comparison would be false; call it only after main starts. Matrix::Decompose uses a different, file-local threshold (FLT_EPSILON, twice as large, with <=), and the ray–plane and frustum code use their own 1e-5 constants; see Math internals: the two epsilons.
  • WithinEpsilon, Clamp(intcs, …) and ClosestMSAAPower are FNA-internal helpers that CNA makes public (C++ has no assembly-internal visibility); XNA's public MathHelper has 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 TARGET headers; 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:

InputResult 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 matrixThe 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 directionA zero vector is normalised: non-finite entries, no exception.
Matrix::CreateBillboard with object and camera at the same pointGuarded 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 normalReturns v unchanged; the normal is never normalised, so a non-unit normal scales the reflection.
Perspective buildersThe only argument checks in the module's transforms: std::invalid_argument for a non-positive near or far distance, near ≥ far and, for CreatePerspectiveFieldOfView, a field of view outside (0, π). The upper guard is the literal 3.141593f, which rounds one step above MathHelper::Pi, so a field of view of exactly MathHelper::Pi is accepted and builds a collapsed, flipped projection where XNA throws. The aspect ratio and the orthographic builders are not validated at all.
Matrix::Decompose with a (near-)zero axis scaleReturns false with rotation set to Quaternion::Identity; scale and translation are still written and meaningful. Decompose derives each axis sign from the product of the row's four elements, so it is not a mirroring test; use the sign of the 3×3 determinant for handedness.

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 TARGET headers:

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 std::logic_error; 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, exactly where it measured the difference against the genuine runtime: Matrix::Multiply, Matrix::Invert, Vector3::Transform and Vector3::TransformNormal by a matrix, and BoundingSphere::CreateFromPoints. The MathHelper interpolators use double for a different reason (their comments cite lost precision), and BoundingBox::Contains(BoundingSphere) and Intersects(BoundingSphere) accumulate their squared distance in double (the source gives no reason and no math oracle suite covers the two functions). Everything else is single precision, including Vector4::Transform, Vector2::Transform, the quaternion operations and, notably, Vector3::Length, LengthSquared, Distance and DistanceSquared, although the same measurement found XNA accumulating those wide; only private helpers inside BoundingSphere.cpp follow the wide rule for lengths. 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 from XNA, and from earlier CNA releases, in the last bit, and an exact-boundary containment test can flip.

What the tests pin

At TARGET the math test directory holds 27 files with 873 statically counted GoogleTest definitions (not executed for this page). 586 assertions use EXPECT_FLOAT_EQ (GoogleTest's four-ULP comparison); explicit tolerances are 1e-5 (75 uses), 1e-6 (64) and 1e-4 (4), and the per-file kEps is 1e-5 except in BoundingFrustumTests.cpp, where it is 1e-4. Two suites compare bits with recorded answers of the genuine XNA framework: MatrixOracleTests (444 cases) and BoundingSphereOracleTests (445 point sets); the colour and packed-vector rounding cases are checked from the graphics tree. Most other expectations are derived analytically. The inverted-range Clamp, NaN and signed-zero operands, zero-vector normalisation, singular inversion, the field-of-view upper bound and the scalar-division rounding have no test; 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 009d40f5 and the decompiled XNA 4.0 Microsoft.Xna.Framework assembly; nothing was run.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture