Tutorial 51: Custom Vertex Types
What you’ll learn
- Describing a vertex layout CNA does not ship with a plain struct and a
VertexDeclaration. - The
VertexElement,VertexElementFormatandVertexElementUsagebuilding blocks (12 formats, 13 usages). - Why offsets and
sizeofalignment must agree with the declaration — and why a struct that implementsIVertexTypeis not a good custom vertex. - Checking the layout at compile time rather than debugging it on the GPU, and uploading it with
VertexBuffer::SetData<T>.
Before you start — Tutorial 38: Vertex Buffers and Index Buffers (what a VertexDeclaration is for) and Tutorial 40: Primitive Types (how vertices are consumed). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls.
CNA ships seven built-in vertex types: VertexPositionColor, VertexPositionTexture, VertexPositionColorTexture, VertexPositionNormalTexture, and the CNAEXT VertexPositionNormalTangentTexture, VertexPositionNormalTextureSkinned and VertexPositionNormalTangentTextureSkinned. For anything else — extra texture-coordinate sets, per-vertex data your own shader consumes, packed colours — you describe your own vertex layout with a VertexDeclaration built from VertexElements and upload plain structs into a VertexBuffer.
IVertexType interface
IVertexType is the small interface CNA's built-in vertex structs implement so that VertexBuffer can ask a vertex type for its declaration:
#include "Microsoft/Xna/Framework/Graphics/IVertexType.hpp"
namespace Microsoft::Xna::Framework::Graphics {
class IVertexType {
public:
virtual ~IVertexType() = default;
// Returns the VertexDeclaration describing the layout
virtual const VertexDeclaration& getVertexDeclarationProperty() const = 0;
};
} // namespace
Do not implement IVertexType on your own vertex struct. The interface has a virtual destructor and a pure virtual function, so every implementing struct carries a hidden vtable pointer: sizeof is larger than the sum of its fields, member offsets shift, offsetof is only conditionally supported, and a raw byte upload of sizeof(T) would copy the pointer to the GPU. CNA's own tests say the same thing, and its custom vertex structs are plain PODs paired with a separately built VertexDeclaration. The built-in types get away with IVertexType because VertexBuffer has dedicated SetData overloads that copy them field by field.
VertexElement
A VertexElement describes one attribute. Its four values are set through the four-argument constructor (the members themselves are private; read them back with getOffsetProperty(), getVertexElementFormatProperty(), getVertexElementUsageProperty() and getUsageIndexProperty()):
VertexElement(int offset, // byte offset from the start of the vertex
VertexElementFormat format, // data type / component count
VertexElementUsage usage, // semantic (what this attribute means)
int usageIndex); // for multiple texcoords or colours (0..15)
VertexElementFormat enum
| Format | C++ type | Size |
|---|---|---|
Single | float | 4 bytes |
Vector2 | float[2] | 8 bytes |
Vector3 | float[3] | 12 bytes |
Vector4 | float[4] | 16 bytes |
Color | uint8_t[4] RGBA | 4 bytes |
Byte4 | uint8_t[4] | 4 bytes |
Short2 | int16_t[2] | 4 bytes |
Short4 | int16_t[4] | 8 bytes |
NormalizedShort2 | int16_t[2], normalised to −1..1 | 4 bytes |
NormalizedShort4 | int16_t[4], normalised to −1..1 | 8 bytes |
HalfVector2 | two 16-bit floats (HiDef only) | 4 bytes |
HalfVector4 | four 16-bit floats (HiDef only) | 8 bytes |
The default Reach profile accepts formats up to NormalizedShort4; the two half-float formats need GraphicsProfile::HiDef and otherwise throw NotSupportedException. A declaration is also rejected above 255 bytes of stride, above 16 elements, or with a usage index outside 0–15.
VertexElementUsage enum
| Usage | Typical data | Notes |
|---|---|---|
Position | Vertex position (XYZ) | The stock effects require it |
Normal | Surface normal (XYZ) | Lit stock effects |
TextureCoordinate | UV coordinates | Usage index 0 = first UV set, 1 = second (DualTextureEffect) |
Tangent | Tangent for normal mapping | The shipped tangent type packs it as a Vector4 (w = handedness) |
Binormal | Binormal / bitangent | |
Color | Per-vertex colour | |
BlendIndices | Bone indices for skinning | |
BlendWeight | Bone weights for skinning | |
Depth, Fog, PointSize, Sample, TessellateFactor | Rarely used XNA usages | Exist for completeness (13 usages in all) |
The old idea that each usage maps to a fixed GLSL attribute name (aPosition, aTexCoord0, …) is not a CNA contract. The stock programs resolve their inputs from the (usage, usage index) pair and use internal attribute names of their own. For a custom ShaderEffect on the EasyGL identities, the attribute location is simply the element's index in the declaration, per-vertex elements first and per-instance elements after them — write layout(location = 0..N) in ... in the order you listed the elements.
sizeof alignment
Make your vertex struct a plain aggregate: no base class, no virtual functions, fields laid out in the order of the declaration. Built from floats and CNA's Vector2/Vector3/Vector4 value types it is trivially copyable and standard-layout, and the compiler inserts no padding between 4-byte-aligned fields. If you use narrower fields (a uint8_t[4] colour, int16_t pairs) or need a stride the compiler would not produce, add #pragma pack(push, 1) (or __attribute__((packed))) — the GPU expects the byte layout to match the VertexDeclaration exactly, and SetData<T> requires std::is_trivially_copyable_v<T>.
A tangent-space vertex: struct + declaration
// CustomVertexTypes.hpp
#pragma once
#include <cstddef>
#include <type_traits>
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexDeclaration.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexElement.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexElementUsage.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexElementFormat.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
// A plain struct: no virtual functions, no base class.
struct NormalMappedVertex {
Vector3 Position; // 0 bytes, 12 bytes
Vector3 Normal; // 12 bytes, 12 bytes
Vector2 TexCoord; // 24 bytes, 8 bytes
Vector3 Tangent; // 32 bytes, 12 bytes
Vector3 Binormal; // 44 bytes, 12 bytes
// 56 bytes total per vertex
};
// The declaration lives in a function-local static, so it is built once on first use.
inline const VertexDeclaration& NormalMappedVertexDeclaration()
{
static const VertexDeclaration declaration(
static_cast<int>(sizeof(NormalMappedVertex)), // stride: total bytes per vertex
{
VertexElement( 0, VertexElementFormat::Vector3,
VertexElementUsage::Position, 0),
VertexElement(12, VertexElementFormat::Vector3,
VertexElementUsage::Normal, 0),
VertexElement(24, VertexElementFormat::Vector2,
VertexElementUsage::TextureCoordinate, 0),
VertexElement(32, VertexElementFormat::Vector3,
VertexElementUsage::Tangent, 0),
VertexElement(44, VertexElementFormat::Vector3,
VertexElementUsage::Binormal, 0),
});
return declaration;
}
Do not name a member of the vertex type VertexDeclaration: inside the class the name would change the meaning of the VertexDeclaration type. CNA's own convention for its shipped types is a static function, getVertexDeclarationStatic().
You may not need to write this at all for normal mapping. CNA ships the CNAEXT type VertexPositionNormalTangentTexture (Position at 0, Normal at 12, a Vector4 Tangent at 24 whose w component is the handedness, and TextureCoordinate at 40; stride 48). glTF import produces it, PbrEffect consumes it, and VertexBuffer::SetData has an overload for it. Reach for a custom layout when you need something the seven shipped types do not cover.
Using the custom vertex with a shader
// Build your mesh
std::vector<NormalMappedVertex> verts;
// ... fill verts with geometry ...
// Create a vertex buffer using the custom declaration
auto vb = std::make_unique<VertexBuffer>(
getGraphicsDeviceProperty(),
NormalMappedVertexDeclaration(),
static_cast<int>(verts.size()),
BufferUsage::None);
// SetData<T> accepts any trivially copyable vertex struct and copies
// count * sizeof(T) bytes; sizeof(T) must equal the declaration's stride.
vb->SetData(verts.data(), static_cast<int>(verts.size()));
// Bind and draw. normalMapEffect_ is a ShaderEffect (Tutorial 52) whose
// vertex shader declares layout(location = 0..4) for the five elements above.
auto& gd = getGraphicsDeviceProperty();
gd.SetVertexBuffer(vb.get());
for (auto& pass : normalMapEffect_->getCurrentTechniqueProperty()->getPassesProperty()) {
pass.Apply();
gd.DrawPrimitives(PrimitiveType::TriangleList, 0,
static_cast<int>(verts.size()) / 3);
}
A vertex buffer can also be created from a System::Type (VertexBuffer(device, type, count, usage)): the layout is then looked up in CNA's vertex-type registry, where a game's own struct registers itself once. The explicit-declaration form above needs no registration. SetDataRaw(data, count, stride) (CNAEXT) remains available for untyped byte uploads and checks the stride against the declaration. Stock effects (as opposed to your own ShaderEffect) resolve their inputs from the declaration's (usage, usage index) pairs on the renderers that adopt CNA's shared stock-vertex semantics (SDL_GPU and WEBGPU; the EasyGL identities run an equivalent declaration check) rather than from byte stride alone, so keep the usages honest even for a layout you mostly use with a custom shader.
Verifying the layout at compile time
// These assertions catch mismatches between the struct and the declaration
static_assert(std::is_trivially_copyable_v<NormalMappedVertex>,
"SetData<T> needs a trivially copyable vertex type");
static_assert(sizeof(NormalMappedVertex) == 56,
"Vertex struct size mismatch — check packing");
static_assert(offsetof(NormalMappedVertex, Normal) == 12, "Normal offset");
static_assert(offsetof(NormalMappedVertex, TexCoord) == 24, "TexCoord offset");
static_assert(offsetof(NormalMappedVertex, Tangent) == 32, "Tangent offset");
static_assert(offsetof(NormalMappedVertex, Binormal) == 44, "Binormal offset");
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Math value types in C++: object layout, equality, hashing and API shape — Which CNA math types carry a vtable, why Color is 24 bytes on 64-bit hosts, the internal vertex stream structs, output-reference aliasing, exact equality, hash and ToString differences, and the split argument exceptions.
- Vertex declarations, bindings and stream composition — From C++ vertex values to the renderer boundary: stream layouts, VertexDeclaration rules and profile limits, index widths, dynamic updates, VertexBufferBinding, semantic composition, the minimum-offset fold and draw validation order.