Coordinate and composition conventions: handedness, row vectors, depth and clip space
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; the oracle comparison is CNA's recorded matrix corpus, not re-run here. Quaternion order, the depth terms M33/M43 and ToColumnMajor on non-symmetric input are not pinned by any test; the worked example was syntax-checked, not run.
CNA's math types keep XNA's names, but the names do not say which way the axes point, which side of a vector a matrix is applied from, in which order two rotations happen or what depth range a projection produces. This page states those conventions exactly as the TARGET source implements them, shows the arithmetic that proves each one, and lists what the tests do and do not pin. It is for anyone writing camera, transform or shader-upload code, and for anyone porting an XNA or OpenGL-trained codebase. The type-by-type reference is Math types; the maintainer view of the same code is Math module internals.
A right-handed basis with Forward = −Z
The direction constants in Vector3.cpp fix the basis: Vector3::Right is (1, 0, 0), Vector3::Up is (0, 1, 0), Vector3::Forward is (0, 0, −1) and Vector3::Backward is (0, 0, 1), with Left and Down the negations. That is XNA's right-handed world: X to the right, Y up, and a camera that looks down −Z. Vector3Test.DirectionConstantsMatchXnaConvention pins the constants. Matrix.hpp describes the type as "a right-handed 4x4 matrix", and there are no left-handed variants of any builder: no CreateLookAtLH, no CreatePerspectiveLH. Code ported from a left-handed engine has to flip Z itself.
A Matrix used as a world transform stores its axes in rows. The accessors in Matrix.cpp read them that way: getRightProperty() is (M11, M12, M13), getUpProperty() is (M21, M22, M23), getBackwardProperty() is (M31, M32, M33), getForwardProperty(), getDownProperty() and getLeftProperty() are the negated rows, and getTranslationProperty() is row four (M41, M42, M43). Matrix::CreateWorld(position, forward, up) fills exactly those rows: it normalises forward, takes right = forward × up and up = right × forward, and stores forward through setForwardProperty, so row three holds the negated forward vector. Every angle parameter in the module is in radians (the rotation builders name the parameter radians); MathHelper::ToRadians and ToDegrees are the only degree-aware functions.
Row vectors: matrices apply from the right, in reading order
CNA follows XNA's row-vector convention. A position is a row multiplied on the left of the matrix, p′ = p·M. Vector3::Transform(position, matrix) computes
x' = x*M11 + y*M21 + z*M31 + M41
y' = x*M12 + y*M22 + z*M32 + M42
z' = x*M13 + y*M23 + z*M33 + M43
so translation lives in M41 to M43, and the fourth column (M14, M24, M34, M44) is never read. Matrix::Multiply(a, b) is the ordinary row-by-column product, entry (i, j) = Σ a(i, k)·b(k, j), which makes p·(A·B) = (p·A)·B: the left operand is applied first. A product therefore reads in the order the transforms happen:
const Vector3 p(1.0f, 0.0f, 0.0f);
const Matrix spin = Matrix::CreateRotationZ(MathHelper::PiOver2);
const Matrix move = Matrix::CreateTranslation(10.0f, 0.0f, 0.0f);
const Vector3 a = Vector3::Transform(p, spin * move); // about (10, 1, 0): spin in place, then move
const Vector3 b = Vector3::Transform(p, move * spin); // about (0, 11, 0): move, then orbit the origin
CreateRotationZ(θ) writes M11 = cos θ, M12 = sin θ, M21 = −sin θ, M22 = cos θ, so the X axis turns towards +Y. Because the sine and cosine are computed in single precision, cos(π/2) is about −4.4e-8 rather than zero, which is why the comments say "about". The usual world matrix is therefore scale * rotation * translation, and the full vertex path is pclip = pobject · World · View · Projection. Tutorial 33 teaches the same order with a scene graph; OpenGL texts write the equivalent column-vector chain Projection * View * World * v, which is the transpose of this one and must not be copied literally.
TransformNormal (on Vector2 and Vector3; Vector4 has none) is Transform without the M4x terms. It multiplies by the matrix itself, not by its inverse transpose, so a normal stays perpendicular only under rotation and uniform scale. Under a non-uniform scale transform normals with the inverse transpose of the world matrix and renormalise; the stock effects already do this for their own lighting.
The ToColumnMajor bridge does not transpose
Matrix::ToColumnMajor(float out[16]) is a CNAEXT helper that copies the sixteen fields in declaration order, M11, M12, M13, M14, M21 … M44. It performs no numeric transpose. The name describes how the receiver reads the bytes: a GL-style API that takes the array as column-major storage sees column i equal to CNA's row i, and a GLSL shader that computes M * v with column vectors then produces exactly what the row-vector product v * M produces on the CPU. PortableGLRenderer.cpp states the rule in a comment ("the same CNA row-major -> GL column-major conversion" that EasyGL uses), and it is why the stock effects (BasicEffect, SkinnedEffect, EnvironmentMapEffect, DualTextureEffect, AlphaTestEffect and the PBR effects) call ToColumnMajor at the upload boundary instead of transposing. The only unit test, MatrixTest.ToColumnMajorIdentity, uses the identity matrix, which is its own transpose; it cannot tell a copy from a transpose, so the evidence for the convention is the renderer upload code and the rendered output of non-identity transforms, not that test. The user-level upload pattern is on Shader effects: setting uniforms.
Do not transpose a matrix, or swap its near and far terms, inside shared math to make one renderer's output look right. Every other renderer and every compiled effect expects the XNA layout. When a result looks transposed or depth looks inverted, inspect that renderer's upload and clip-space translation first.
Quaternion products run the other way
Quaternion::Multiply(q1, q2) (and q1 * q2) in Quaternion.cpp is the ordinary Hamilton product, and Vector3::Transform(v, q) is the rotation q·v·q*. So rotating by q1 * q2 applies q2 first, the opposite reading order from Matrix. The two conventions agree once converted: Matrix::CreateFromQuaternion(q1 * q2) equals CreateFromQuaternion(q2) * CreateFromQuaternion(q1). Quaternion::Concatenate(value1, value2) is documented as "value1 followed by value2", and its four component expressions are term for term the product value2 * value1; use it when the call site should read in the order the rotations happen.
const Quaternion tilt = Quaternion::CreateFromAxisAngle(Vector3::UnitX, MathHelper::PiOver4);
const Quaternion turn = Quaternion::CreateFromAxisAngle(Vector3::UnitY, MathHelper::PiOver2);
const Quaternion tiltThenTurn = Quaternion::Concatenate(tilt, turn); // == turn * tilt
const Matrix sameAsMatrices = Matrix::CreateFromQuaternion(tilt) * Matrix::CreateFromQuaternion(turn);
Quaternion::CreateFromYawPitchRoll(yaw, pitch, roll) expands, term by term, to qyaw ⊗ qpitch ⊗ qroll with yaw about Y, pitch about X and roll about Z. With the rightmost factor applied first, a vector is rolled, then pitched, then yawed, all about the fixed world axes (equivalently yaw, pitch, roll about the body's own axes). Matrix::CreateFromYawPitchRoll builds that quaternion and converts it, so both types share one order. Both the matrix and the quaternion default constructors produce all zeros, not the identity: use Matrix::getIdentityProperty() or Quaternion::Identity.
The single-axis routes agree, so a positive angle turns the same way on both: Matrix::CreateFromQuaternion(Quaternion::CreateFromAxisAngle(Vector3::UnitZ, a)) has the same M11, M12, M21 and M22 as Matrix::CreateRotationZ(a) (cos a, sin a, −sin a, cos a), and likewise about X and Y. CreateFromAxisAngle does not normalise its axis, so pass a unit vector.
Lerp and Slerp
Quaternion::Lerp takes the dot product of the two inputs, negates the second when the dot is negative (so it follows the shorter arc), mixes the components linearly and normalises the result. Quaternion::Slerp also flips to the shorter arc, then weights by sin((1−t)θ)/sin θ and sin(tθ)/sin θ, which gives uniform angular speed; when |dot| exceeds 0.999999 it falls back to the linear weights 1−t and t without normalising, because sin θ is then too close to zero to divide by. For small turns the two agree closely; over a long, slow rotation Lerp visibly speeds up in the middle of the arc and slows at the ends. Neither clamps amount. A typical per-frame turn toward a heading:
const Quaternion target = Quaternion::CreateFromYawPitchRoll(MathHelper::PiOver2, 0.0f, 0.0f);
facing = Quaternion::Slerp(facing, target, std::min(1.0f, turnSpeed * deltaSeconds));
const Matrix orientation = Matrix::CreateFromQuaternion(facing);
Feeding a fraction of the remaining angle each frame is an exponential ease, not a constant-speed turn; for constant speed keep the start orientation fixed and advance t by time, as Tutorial 43 does.
The [0, 1] depth convention, derived three times
XNA's projections produce Direct3D clip space: after the perspective divide, x and y lie in [−1, 1] and depth in [0, 1]. Three independent places in TARGET agree.
- The projection builders.
CreatePerspectiveFieldOfViewsets M33 = f/(n−f), M34 = −1, M43 = n·f/(n−f) and M44 = 0. For a view-space point at z = −n this gives clip z = 0 with w = n; at z = −f it gives clip z = w = f, so depth runs 0 at the near plane to 1 at the far plane. The orthographic builders set M33 = 1/(n−f) and M43 = n/(n−f) with M44 = 1: the same mapping without the divide.CreatePerspectiveandCreatePerspectiveOffCenteruse the same depth terms. - The frustum planes.
BoundingFrustum.cppextracts Near = −(column 3), Far = column 3 − column 4, Left = −(column 4) − column 1, Right = column 1 − column 4, Top = column 2 − column 4 and Bottom = −(column 4) − column 2, then normalises each. Evaluated as N·p + D for a row vector these are −zc, zc − wc, −wc − xc, xc − wc, yc − wc and −wc − yc: inside means −w ≤ x, y ≤ w and 0 ≤ z ≤ w, and every normal points outward. The near plane comes from the third column alone precisely because the near clip is z ≥ 0, not z ≥ −w. This is XNA's own extraction, read in the XNA 4.0BoundingFrustum. - The Software renderer's clipper.
SoftwareRenderer.cppclips in homogeneous space against x + w, w − x, y + w, w − y, z and w − z (ClipPlaneDistance): the same 0 ≤ z ≤ w volume.
Renderers whose native API expects another range translate at their own boundary and leave application matrices alone. OpenGL's clip volume is −w ≤ z ≤ w, so the stock GLSL programs generated from GlStockShaderSources.hpp (used by the EasyGL identities and OpenGL4) end every vertex shader with gl_Position.z = gl_Position.z * 2.0 - gl_Position.w. The source comment records that MojoShader applies the same conversion to classic compiled effects, so stock and compiled draws land on one depth scale (EasyGLCompiledEffectDrawTest.IsDepthTestedOnTheSameScaleAsStockGeometry guards the mix). Application matrices and effect parameters stay XNA-shaped on every renderer.
Keep W: Vector3 versus Vector4 transforms
Because Vector3::Transform ignores the fourth column, Vector3::Transform(position, projection) returns x, y and z of the clip-space point without its w and without dividing by it. For an orthographic projection that is harmless (w stays 1); for a perspective projection the result is not a screen position. Vector4::Transform accepts a Vector2, Vector3 or Vector4 together with a Matrix and returns all four clip components, including w = x·M14 + y·M24 + z·M34 + M44; the divide is left to the caller. The Software renderer uses exactly this route: BuildPositionColorClipVertex calls Vector4::Transform(position, combined), clips the polygon in clip space (where linear interpolation is still valid) and divides only afterwards. By hand:
bool ToNdc(const Vector3& position, const Matrix& world, const Matrix& view,
const Matrix& projection, Vector3& ndc)
{
const Vector4 clip = Vector4::Transform(position, world * view * projection);
if (!(clip.W > 0.0f))
return false; // behind the eye (or degenerate): no screen position
if (clip.X < -clip.W || clip.X > clip.W || clip.Y < -clip.W || clip.Y > clip.W ||
clip.Z < 0.0f || clip.Z > clip.W)
return false; // outside the [0, w] Direct3D clip volume
ndc = Vector3(clip.X / clip.W, clip.Y / clip.W, clip.Z / clip.W);
return true;
}
Testing w before dividing matters: a point behind the camera has negative w, and dividing by it mirrors the point onto the screen. For screen coordinates of a whole scene, Viewport::Project and Unproject perform the same steps with the viewport's rectangle and depth range; Math types: code examples builds a picking ray from two Unproject calls. Vector3::Transform accumulates in double and narrows once (to reproduce XNA's x87 arithmetic, see which arithmetic is wider than float); Vector4::Transform is plain single precision.
A worked world–view–projection chain
The following Draw override rotates a triangle 30 degrees about Z, views it from z = 3 and projects it with a 45-degree field of view. It uses the public fields of BasicEffect (World, View, Projection, VertexColorEnabled) and applies the first pass the XNA way; Effect::Apply() also exists but is a CNAEXT shortcut. It was syntax-checked against the TARGET headers (g++ -std=c++23 -fsyntax-only -DCNA_RENDERER_SDL_RENDERER -DCNA_PLATFORM_SDL3 with every TARGET module include directory and a sharp-runtime next checkout at 41b918c9, which TARGET does not pin); it was not run. It needs a 3D-capable renderer.
void Draw(const GameTime& gameTime) override
{
GraphicsDevice& device = getGraphicsDeviceProperty();
device.Clear(Color::CornflowerBlue);
device.setRasterizerStateProperty(RasterizerState::CullNone);
const Matrix world = Matrix::CreateRotationZ(MathHelper::ToRadians(30.0f)) *
Matrix::CreateTranslation(0.0f, 0.0f, 0.0f); // rotate, then place
const Matrix view = Matrix::CreateLookAt(Vector3(0.0f, 0.0f, 3.0f), Vector3::Zero, Vector3::Up);
const Matrix projection =
Matrix::CreatePerspectiveFieldOfView(MathHelper::PiOver4, 1.0f, 0.1f, 100.0f);
BasicEffect effect(device);
effect.VertexColorEnabled = true;
effect.World = world;
effect.View = view;
effect.Projection = projection;
effect.getCurrentTechniqueProperty()->getPassesProperty()[0]->Apply();
const VertexPositionColor triangle[3] = {
VertexPositionColor(Vector3(0.0f, 0.8f, 0.0f), Color::Red),
VertexPositionColor(Vector3(-0.8f, -0.6f, 0.0f), Color(0, 220, 90)),
VertexPositionColor(Vector3(0.8f, -0.6f, 0.0f), Color(60, 140, 255)),
};
device.DrawUserPrimitives(PrimitiveType::TriangleList, triangle, 0, 1);
Game::Draw(gameTime);
}
CullNone is set because the device's default rasterizer state is RasterizerState::CullCounterClockwise: XNA treats clockwise triangles as front faces and culls counter-clockwise ones. The three vertices above (top, lower left, lower right) appear counter-clockwise on screen, so under the default state this triangle would be culled and nothing would draw. The same winding is visible in the math: in a Y-up view the Z component of Vector3::Cross(b - a, c - a) is positive for counter-clockwise vertices, and exchanging b and c reverses both the normal and the culling decision. For the full 3D setup see 3D rendering: camera setup.
object space world space view space clip space NDC / depth
(mesh vertices) -> (scene) -> (camera at origin, -> (x, y, z, w) -> x/w, y/w in [-1, 1]
p * World looking down -Z) p * Projection z/w in [0, 1]
p * View clip: -w<=x,y<=w
0<=z<=w
row vector on the left; each matrix applies after the previous one; W survives to the divide
GL renderers only: gl_Position.z = z*2 - w (moves 0..w to -w..w at the renderer boundary)
What pins these conventions, and what does not
- Oracle-compared (recorded by CNA, not re-run here).
MatrixOracleTests.cppcompares CNA's bits with the genuine XNA 4.0 framework's answers inmatrix-oracle.txt: 168 products (120 random), rotations about each axis, inversions and position and normal transforms. Products of random, non-commuting matrices that match bit for bit also pin which operand is applied first. - Unit-tested. The direction constants; the projection builders' M34 = −1 and thrown cases (
MatrixTest.CreatePerspective*M34IsMinusOne);CreateLookAtM44IsOne;MultiplyTwoTranslationsAddsThem(translations commute, so it does not pin order). - Not pinned by any test. Quaternion multiplication order (the quaternion tests multiply by the identity and by a conjugate only),
CreateFromYawPitchRollat non-zero angles, the depth terms M33 and M43 (exercised only indirectly throughViewportand frustum consumers),ToColumnMajoron a non-symmetric matrix, and the camera builders as whole matrices, which are not in the oracle corpus (recorded as a verification gap; see Known Issues).
Everything on this page was checked by reading the TARGET source and tests at 009d40f5, and the XNA 4.0 statements by reading the decompiled genuine Microsoft.Xna.Framework assembly; nothing was executed. Tutorial 42 and Tutorial 43 are the hands-on companions.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Math types: core types · Tutorial 33: matrices and transformations · Tutorial 43: quaternions
- Architecture
- Graphics architecture
- Internals
- Math internals: row vectors and the view and projection terms · Math internals: wider-than-float arithmetic
- Maintainer workflow
- I need to change public XNA behaviour
- Tests and validation
- Test architecture