Math value types in C++: object layout, equality, hashing and API shape

CNA snapshot 3b43bd86  ·  Deep Dives › Framework core  ·  source links pinned to 3b43bd86

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). 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. Object sizes are the values CNA's own comments and tests record, not measured here. XNA comparisons come from the decompiled XNA 4.0 framework assembly and the .NET Framework 4 mscorlib IL; nothing was executed.

XNA's math types are small managed value types whose field list is their whole representation. CNA keeps their names, fields and operations, but a C++ struct that implements a polymorphic interface is not four floats or four bytes any more. This page explains which math types are plain data and which carry a vtable, why Color is 24 bytes on a 64-bit host, how pixel and vertex bytes are kept apart from the objects, how the value-returning and output-reference overloads differ, and what equality, hashing, text formatting and argument errors really do. It is for anyone who copies, serialises, hashes or uploads math values, and for porters who rely on XNA's value semantics. The general C#-to-C++ translation rules are on From C# to C++.

Values, not objects — with two exceptions

Vector2, Vector3, Vector4, Matrix, Quaternion, Plane, Ray, Point, Rectangle, Color, BoundingBox and BoundingSphere are structs with public fields (or, for Color, one private packed word behind accessors). Copying one copies its value; no heap allocation, identity or ownership is involved, and nothing needs disposing. Two types behave more like small objects:

  • BoundingFrustum is a class that stores its matrix and caches six planes and eight corners derived from it. setMatrixProperty recomputes both; equality and hashing look only at the stored matrix, so two frustums built from the same matrix compare equal whatever their cached planes' last bits.
  • Curve owns a CurveKeyCollection by value, and the collection owns a vector of CurveKey objects. Copying a curve copies all keys; getKeysProperty() returns a reference, and the non-const indexer returns a mutable CurveKey&, so editing a key changes the curve in place. See Curve evaluation.

Default construction follows the XNA value type's default(T), which is all zeros: a default Matrix is the zero matrix, not the identity, and a default Quaternion is (0, 0, 0, 0), not a rotation. Use Matrix::getIdentityProperty() (a function-local static, so it has no static-initialisation-order hazard) or Quaternion::Identity. A default Color is transparent black.

Value-returning and output-reference overloads

XNA offers most operations twice: a value-returning form and a ref/out form for performance. CNA spells the second as const T& inputs plus a trailing T& result: Vector3::Normalize(value) and Vector3::Normalize(value, result), Matrix::Invert(m) and Matrix::Invert(m, result). They are separate declarations, tested separately, and in CNA the value form usually delegates to the reference form.

Whether a reference form may be called with the same object as input and output depends on the implementation, and the C++ signature does not say. At TARGET every reference form read for this page tolerates it, so the C# idioms Matrix.Transpose(ref m, out m) or Vector3.Normalize(ref v, out v) port literally: Matrix::Multiply and Matrix::Invert read all sixteen inputs into locals before writing; the transforms, Vector3::Cross and the quaternion products compute into locals first; the component-wise operations read and write one component at a time; the scalar outputs of Dot, Distance and DistanceSquared may even alias a component of an input. Two forms were fixed after the 0.1.0 release. Matrix::Transpose(const Matrix&, Matrix&) now builds the transposed matrix from all sixteen source fields before it assigns, as XNA's Matrix.Transpose(ref Matrix, out Matrix) does; the release assigned field by field, so Matrix::Transpose(m, m) symmetrised the matrix instead of transposing it, and Plane::Transform(plane, matrix), which makes that call internally, returned wrong planes (see Transforming a plane). Rectangle::Union(a, b, result) now computes the union before it assigns, so either input may also be the output; the release overwrote X and Y before computing the width and height from them. MatrixTransposeCompatibilityTest.AliasedOutputPreservesBitsAndIsAnInvolution and IntegerValueCompatibilityTest.RectangleUnionPreservesBothInputAliases pin the two aliased calls. One aliasing rule is XNA's own and deliberately kept: when Matrix::Decompose receives the same Vector3 for scale and translation, the scale, written last, wins (MatrixDecomposeCompatibilityTest.ScaleOutputWinsWhenAliasedWithTranslation).

Which types carry a vtable

C# interfaces become C++ abstract base classes with virtual functions and a virtual destructor, and each such base adds a vtable pointer to the object. The math and vertex types fall into three groups:

TypePolymorphic basesConsequence
ColorGraphics::PackedVector::IPackedVectorT<UInt32> (itself derived from IPackedVector, with pure virtual PackFromVector4, ToVector4 and the packed-value accessors) and System::IEquatable<Color>Two vtable pointers precede the single 32-bit packed word. The object is not four RGBA bytes.
BoundingBox, BoundingSphere, BoundingFrustum, CurveKeySystem::IEquatable<T> (pure virtual Equals, virtual destructor, in sharp-runtime); CurveKey additionally implements System::IComparable<CurveKey>, a second polymorphic baseThe field list is not the complete object representation.
Built-in vertex types (VertexPositionColor and the others)IVertexTypeA vertex object cannot be uploaded by copying sizeof(T) bytes.
Vector2, Vector3, Vector4, Matrix, Quaternion, Plane, Ray, Point, Rectangle, Curve, CurveKeyCollectionnonePlain field layout (for Matrix: sixteen floats M11 … M44 in row order). Padding and object rules still belong to the C++ ABI, not to an XNA contract.

IPackedVector and IPackedVectorT live in the math module (IPackedVector.hpp), although their namespace is Graphics::PackedVector, because Color needs them; the seventeen packed-vector types in the graphics module implement the same interface (see PackedVector types). MathHelper is a final class with a deleted (CNAEXT) constructor, holding only static members.

On a 64-bit host the two vtable pointers plus the four-byte word and alignment padding make sizeof(Color) == 24. That number appears in the graphics sources (Texture3D.cpp and TextureCube.cpp: "Color has a vtable pointer (sizeof(Color) == 24), so we must never pass Color* directly to GL") and in a test comment of VertexPositionColorTests.cpp (XNA's stride is 16, "but sizeof(Color) is currently 24"). It is an ABI fact, not an API promise: the regression test ColorTest.SizeIsLargerThanFourBytesVtablePresent asserts only that sizeof(Color) exceeds four bytes, and on a 32-bit target the pointers, and so the size, are smaller.

Pixel and vertex bytes never alias the objects

Reinterpreting a Color* as pixel bytes reads or overwrites a vtable pointer. CNA's own texture and back-buffer paths therefore convert explicitly in both directions:

  • GraphicsDevice::GetBackBufferData(Color*, …) in GraphicsDevice.cpp (GetBackBufferDataCore) reads the renderer's RGBA bytes into a temporary byte vector and assigns Color(p[0], p[1], p[2], p[3]) per pixel; only the non-Color element types are copied with memcpy.
  • Texture3D and TextureCube unpack Color arrays into plain RGBA byte vectors (colorsToRgba) before handing them to a renderer.
  • ColorTest.ConstructedFromRawRgbaBytesYieldsCorrectComponents guards the conversion; it was added when an earlier back-buffer read cast Color* to bytes (a fixed, historical defect).

Application code should do the same: use the component accessors, getPackedValueProperty(), and the typed SetData/GetData overloads, and never serialise a math object with memcpy(&value, …, sizeof(T)) unless the file format is deliberately tied to one compiler and ABI. The C ABI never exposes these layouts: it defines its own plain structs (a row-major CNA_Matrix, CNA_Color as four uint8_t channels), described on Math internals: layout and object shape.

The internal stream structs

For vertices the graphics core keeps a second, plain-data representation in BuiltInVertexStreams.hpp. Its header comment gives the reason: XNA's vertex types are blittable, so there sizeof(T) is the GPU stride, but in CNA Color and IVertexType are polymorphic and a vertex object "places its members at alignment-driven offsets that have nothing to do with the stream". Each stream struct has no base class, and both its size and every member offset are static_asserted:

Stream structBytesPublic vertex type
PositionColorStream16VertexPositionColor
PositionTextureStream20VertexPositionTexture
PositionColorTextureStream24VertexPositionColorTexture
PositionNormalTextureStream32VertexPositionNormalTexture
PositionNormalTangentTextureStream48VertexPositionNormalTangentTexture (CNAEXT)
PositionNormalTextureSkinnedStream52VertexPositionNormalTextureSkinned (CNAEXT)
PositionNormalTangentTextureSkinnedStream68VertexPositionNormalTangentTextureSkinned (CNAEXT)
PositionNormalTangentTexture2ColorStream, PositionNormalTangentTextureSkinned2Stream, PositionNormalTangentTextureSkinned2ColorStream60, 76, 80none: internal PBR/glTF layouts with a second texture-coordinate set and optional vertex colour

The VertexStreamOf template maps each public vertex type to its stream, every built-in VertexDeclaration takes its stride from sizeof(stream) and its element offsets from offsetof, and the Pack() overloads are the one place a vertex object becomes stream bytes. Positions, normals and texture coordinates are copied as floats; a Color contributes its four channel bytes in R, G, B, A order, which is the little-endian byte order of its packed word. Only the first four public types are XNA's; the others are CNA extensions. How these bytes reach renderers is covered by Draw-call internals.

Equality, hashing and text

Equality is exact. Every math Equals and operator== compares fields with float ==; MathHelper::WithinEpsilon exists but no equality uses it. A NaN component makes a value unequal to itself; q and -q, the same rotation, compare unequal; and +0 and −0 compare equal. XNA behaves the same way. Each value type also has Equals(const std::any&), the C++ spelling of XNA's Equals(object), which is false for a different type (ObjectEqualityContractTests covers fourteen types). Compare computed values with a tolerance of your own.

Hash codes. The return type is not uniform: int for Vector2/3/4, Matrix and Quaternion; intcs for Point and Rectangle; std::size_t for Plane, Ray, Color, CurveKey and the three bounding volumes. Generic code should use auto or its own hash type. Most of the values are XNA's own integers. The vector, matrix and quaternion hashes add the IEEE bit pattern of each component with 32-bit wraparound and map both zeros to 0, as .NET 4's Single.GetHashCode does, so Vector3(0, 0, 0) and Vector3(-0.0f, 0, 0) compare equal and hash alike; MathValueHashTests.cpp pins every zero-sign combination and exact values such as Vector3(1, -0.0f, 0).GetHashCode() == 1065353216. Point and Rectangle add their fields with wraparound, Color returns its packed word, and CurveKey adds its four float hashes and its continuity; its std::size_t carries the 32-bit sum zero-extended, so a hash XNA reports as negative arrives as a large unsigned number. Four types still do not reproduce XNA, which adds its fields' hashes: Plane and Ray combine with XOR (and Plane hashes D with std::hash<float>), BoundingBox uses a boost-style mix and BoundingSphere adds std::hash<float> of its radius. BoundingFrustum hashes its matrix. Since the 0.1.0 release the vector, matrix, quaternion, curve-key, point and rectangle hashes changed (the release hashed the raw bits of a negative zero, XORed the point, rectangle and curve-key fields and could overflow a signed sum), so never persist hash values across CNA versions.

Text. ToString() follows XNA's shapes, {X:1 Y:2 Z:3} for vectors and {X:0 Y:0 Width:10 Height:10} for rectangles; Ray and BoundingBox print one literal outer brace pair, {Min:{X:1 Y:2 Z:3} Max:{X:4 Y:5 Z:6}}, as XNA's string.Format output does (BoundingBoxTest.ToStringMatchesFNAFormat and GeometryValuesCompatibilityTest.BoxAndRayUseOneLiteralOuterBracePair; the 0.1.0 release printed doubled braces); and Matrix::ToString() reproduces XNA's outer { {M11:… } … } format exactly because the content pipeline compares it. A negative zero prints as 0, as .NET's Single formatting does, without changing the stored value (FloatTextHidesNegativeZeroWithoutMutatingValues). The numbers themselves come from std::ostringstream with its defaults (six significant digits, the C++ global locale), whereas XNA formats each component with Single.ToString in the current culture, so long fractions and decimal-comma cultures print differently (CNA-BUG-073). Treat ToString() as a diagnostic string, not a serialisation format; the opt-in XML and text converters are the supported text routes (Design converter internals).

Argument errors: the System exception types

The math module reports a bad argument with the .NET-shaped exception XNA 4.0 throws for the same call, from sharp-runtime's System namespace, and the tests pin each type:

FunctionConditionThrown at TARGET
BoundingBox::CreateFromPoints, BoundingSphere::CreateFromPointsempty listSystem::ArgumentException
BoundingSphere(Vector3, float)negative radiusSystem::ArgumentException
BoundingBox::GetCorners(std::vector<Vector3>&), BoundingFrustum::GetCorners(std::vector<Vector3>&)fewer than eight elementsSystem::ArgumentOutOfRangeException
Matrix::CreatePerspective, CreatePerspectiveFieldOfView, CreatePerspectiveOffCenterfield of view outside (0, π), checked first; non-positive near or far distance; near ≥ farSystem::ArgumentOutOfRangeException naming fieldOfView, nearPlaneDistance or farPlaneDistance; the output matrix is left untouched
range forms of Vector2/3/4::Transform and TransformNormalindex + length beyond either array (computed without overflow)System::ArgumentException
the same range formsa positive length with a negative index (an empty or negative length with an in-range end copies nothing and throws nothing)System::IndexOutOfRangeException
Curve::ComputeTangent, CurveKeyCollection indexer, setItemProperty, RemoveAt; CopyTo with a negative indexindex out of rangeSystem::ArgumentOutOfRangeException
CurveKeyCollection::CopyTodestination too smallSystem::ArgumentException

Since the 0.1.0 release no std::invalid_argument, std::out_of_range or std::logic_error is thrown anywhere in the math module; the release mixed them with the System types. In sharp-runtime, System::ArgumentOutOfRangeException derives from System::ArgumentException, which derives through System::SystemException from System::Exception and std::exception (not from std::invalid_argument); System::IndexOutOfRangeException derives from System::SystemException directly, as in .NET. A catch (const System::ArgumentException&) therefore handles every math argument error except a negative index in the vector range forms, and System::SystemException or std::exception handles all of them. The private, never-called CheckForNaNs helpers would throw System::NotFiniteNumberException. The C ABI's exception barrier reports System::ArgumentException and its subclasses as an invalid-argument result; its vector array routes take unsigned indices and validate the range before calling C++. Degenerate numeric input, by contrast, rarely throws at all: see degenerate inputs.

Conversions and the operator surface

  • Conversions only widen, through constructors: Vector3(Vector2, z), Vector4(Vector2, z, w), Vector4(Vector3, w). There are no narrowing conversions, no conversion operators, no operator<< and no operator[] on vectors or matrices.
  • Point has no Vector2 bridge, as in XNA; write Vector2(static_cast<float>(p.X), static_cast<float>(p.Y)). Converting the other way is a rounding decision the caller has to make visibly (see Point).
  • Compound assignments are CNA extensions and marked CNAEXT: all six on Vector2 and Vector3, *= (matrix and scalar) on Matrix, none on Vector4 or Quaternion. C# synthesises them from the binary operators, so XNA code such as world *= rotation ports unchanged; under CNA_STRICT_XNA_API they become deprecated. The scalar-left Matrix operator*(float, Matrix) is genuine XNA API (FNA lacks it); Color operator*(float, Color) is a CNAEXT convenience.
  • Point also defines +, -, * and /, which XNA 4.0's Point does not have (it has only == and !=). They are not CNAEXT-marked, so the strict-API check does not flag them; code meant to stay XNA-portable should not rely on them.

Evidence

Checked by reading the TARGET headers, sources and tests at c6dec91f, re-checked at 3b43bd86 (the math module, the graphics files linked above, and the C ABI's CnaCApiDetail.hpp and CnaCApiVectors.cpp) and sharp-runtime's IEquatable.hpp and exception headers in a next checkout at 4e146b90 (not pinned by TARGET; the exception hierarchy there has not changed since September 2026). XNA 4.0 statements come from the decompiled genuine Microsoft.Xna.Framework assembly, including the IL of the Plane, Ray, BoundingBox and BoundingSphere hash methods, and the .NET Single.GetHashCode behaviour from the IL of the .NET Framework 4 mscorlib; FNA's ToString was read at FNA b355124. The 24-byte size is the value CNA's own comments record for 64-bit builds; nothing was compiled or run to measure it. Maintainer detail on layout, the unity-build grouping and the C ABI routes is on Math internals.

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

Tests and validation
Test architecture