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

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). 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; 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. Most of the math module is written so that aliasing is harmless: Matrix::Multiply and Matrix::Invert read all sixteen inputs into locals before writing, and the transforms, Vector3::Cross and the quaternion products compute into locals first. Matrix::Transpose(const Matrix&, Matrix&) does not: it assigns the sixteen result fields in order while reading the source, so Matrix::Transpose(m, m) writes M12 before reading it back as the source of M21. Every upper-triangle cell receives its lower-triangle mirror and every lower cell keeps its value: the call symmetrises the matrix instead of transposing it. XNA's own Matrix.Transpose(ref Matrix, out Matrix) copies all sixteen fields into locals first, so the C# idiom Matrix.Transpose(ref m, out m) is correct in XNA and wrong when ported literally. Plane::Transform(plane, matrix) makes exactly this call internally; the consequences are on Transforming a plane. Use the value form Matrix::Transpose(m), which writes a distinct object, or a separate destination.

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 are consistent with nothing but CNA. 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. The values are not XNA's integers either: Rectangle XORs its fields where XNA adds them, and BoundingBox uses a boost-style mix. The vector, matrix, quaternion and curve-key hashes add or XOR the raw IEEE bit pattern of each float (a file-local FloatHash that copies the bits with memcpy). .NET's Single.GetHashCode returns 0 for both zeros; FloatHash does not, so Vector3(0, 0, 0) and Vector3(-0.0f, 0, 0) compare equal but hash differently. Normalise negative zeros before using these values as keys of a hashed container (see Known Issues).

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, and Matrix::ToString() reproduces XNA's outer { {M11:… } … } format exactly because the content pipeline compares it. Two formats differ from XNA: Ray and BoundingBox print literal double braces, {{Min:{X:1 Y:2 Z:3} Max:{X:4 Y:5 Z:6}}}, because FNA builds the string by concatenating a C# format-string escape; XNA passes the same text through string.Format, which turns {{ into one brace. BoundingBoxTest.ToStringMatchesFNAFormat pins the doubled form. 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. 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: two exception families

Sibling functions do not agree on the exception type they throw for the same kind of bad argument, and the tests pin each current choice:

FunctionConditionThrown at TARGET
BoundingBox::CreateFromPointsempty listSystem::ArgumentException
BoundingSphere::CreateFromPointsempty liststd::invalid_argument
BoundingBox::GetCorners(std::vector<Vector3>&)fewer than eight elementsSystem::ArgumentOutOfRangeException
BoundingFrustum::GetCorners(std::vector<Vector3>&)fewer than eight elementsstd::out_of_range
Matrix::CreatePerspective, CreatePerspectiveFieldOfView, CreatePerspectiveOffCenternon-positive distance, near ≥ far, field of view outside (0, π)std::invalid_argument
array forms of Vector2/3/4::Transform and TransformNormalnegative index or length, range beyond either arraystd::out_of_range
Curve::ComputeTangent, CurveKeyCollection indexer, RemoveAt, CopyToindex out of rangestd::out_of_range

XNA throws the ArgumentException family for all of these (for example ArgumentOutOfRangeException from CreatePerspectiveFieldOfView). sharp-runtime's System::ArgumentException derives from System::Exception and from std::exception, but not from std::invalid_argument, so no single catch clause short of std::exception handles every math argument error. A catch written for one type is not a geometry-wide policy; catch std::exception around code that validates through the math module. The C ABI maps all of these to an invalid-argument result, with a range category for std::out_of_range. 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 009d40f5 and sharp-runtime's IEquatable.hpp at 41b918c9 (a next checkout, not pinned by TARGET). XNA 4.0 statements come from the decompiled genuine Microsoft.Xna.Framework assembly, 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