Tutorial 51: Custom Vertex Types

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • Describing a vertex layout CNA does not ship with a plain struct and a VertexDeclaration.
  • The VertexElement, VertexElementFormat and VertexElementUsage building blocks (12 formats, 13 usages).
  • Why offsets and sizeof alignment must agree with the declaration — and why a struct that implements IVertexType is 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

FormatC++ typeSize
Singlefloat4 bytes
Vector2float[2]8 bytes
Vector3float[3]12 bytes
Vector4float[4]16 bytes
Coloruint8_t[4] RGBA4 bytes
Byte4uint8_t[4]4 bytes
Short2int16_t[2]4 bytes
Short4int16_t[4]8 bytes
NormalizedShort2int16_t[2], normalised to −1..14 bytes
NormalizedShort4int16_t[4], normalised to −1..18 bytes
HalfVector2two 16-bit floats (HiDef only)4 bytes
HalfVector4four 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

UsageTypical dataNotes
PositionVertex position (XYZ)The stock effects require it
NormalSurface normal (XYZ)Lit stock effects
TextureCoordinateUV coordinatesUsage index 0 = first UV set, 1 = second (DualTextureEffect)
TangentTangent for normal mappingThe shipped tangent type packs it as a Vector4 (w = handedness)
BinormalBinormal / bitangent 
ColorPer-vertex colour 
BlendIndicesBone indices for skinning 
BlendWeightBone weights for skinning 
Depth, Fog, PointSize, Sample, TessellateFactorRarely used XNA usagesExist 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");