Vertex declarations, bindings and stream composition
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 the pinned snapshot; the binding-offset and multi-stream contracts have unit tests that were not run for this page; the XNA/FNA comparisons are the ones CNA's code comments cite.
This page follows vertex data from public C++ values to the renderer boundary: why CNA's vertex objects are not their own GPU layout, the exact rules a VertexDeclaration enforces, index widths and dynamic-buffer updates, how VertexBufferBinding offsets and instance frequencies are carried, how several streams are composed by semantic, the minimum-offset fold, and the deliberate order of draw-time validation. It is for developers writing custom vertex formats, multi-stream or instanced geometry, and for maintainers of the shared draw layer. The guide-level introduction is 3D rendering, with Tutorial 38, Tutorial 51 and Tutorial 60; the draw is traced function by function on Indexed draw trace.
Public vertex objects are not upload layouts
In XNA the built-in vertex types are blittable value types: sizeof(T) is the distance between consecutive GPU vertices. CNA cannot reproduce that identity. Color derives from the polymorphic IPackedVector interface and the vertex structures derive from IVertexType (which has a virtual destructor), so a vertex object carries a virtual-table pointer and places its members at alignment-driven offsets unrelated to the stream. The real stream layouts are plain structs in BuiltInVertexStreams.hpp, with sizes asserted at compile time: 16 (position + colour), 20 (position + texture), 24 (position + colour + texture), 32 (position + normal + texture), 52 (skinned), 48 (tangent), 60 (tangent + second UV + colour), 68 (skinned tangent), 76 and 80 (skinned tangent variants with a second UV and colour). Each built-in type's VertexDeclaration takes its stride from sizeof of that struct and every element offset from offsetof, so the two cannot drift, and one Pack() function per type is the only place a vertex object becomes stream bytes.
The transfer routes follow from that:
- The typed
VertexBuffer::SetDataoverloads for the seven built-in types (the four XNA types plus the CNAEXTVertexPositionNormalTextureSkinned,VertexPositionNormalTangentTextureandVertexPositionNormalTangentTextureSkinned) and theGraphicsDevice::DrawUserPrimitivesoverloads pack public values into the stream form. - The template
SetData<TVertex>— the C++ form of XNA's genericSetData<T>(T[])— copies bytes verbatim andstatic_asserts thatTVertexis trivially copyable. A custom vertex struct that derives fromIVertexTypeis polymorphic and fails that assertion; write a plain struct and give the buffer an explicitVertexDeclaration. - The CNAEXT
SetDataRaw(const void*, int count, int stride)reads exactlycount × stridecaller bytes; when the buffer has a declaration the stride must equal its stride and every element must fit. The source extent is the caller's promise. Passing an array of polymorphic public objects throughvoid*does not turn their C++ layout into the XNA stride.
VertexDeclaration is the byte contract
Construction rules
A VertexDeclaration (VertexDeclaration.cpp) is immutable after construction: it copies the element list and exposes it only as a const vector. The constructors that take an element list throw ArgumentNullException for an empty list. The stride is either explicit or computed as the largest offset + format size. Construction then validates:
- the stride is positive (
ArgumentOutOfRangeException) and a multiple of four bytes; - every element's usage is a real
VertexElementUsage, lies wholly inside the stride and starts at a multiple of four bytes; - no two elements share a (usage, usage index) pair;
- no two elements overlap in bytes.
The failures after the first are ArgumentException. The public default constructor is a CNAEXT exception to all of this: it creates an empty, zero-stride declaration used only by the legacy VertexBuffer(device, count) convenience path, whose renderer uses its own upload stride. Copies of a declaration share one resource identity, as references to one object do in C#.
Profile limits
ValidateForProfile runs when a VertexBuffer is created and when a user-array draw builds its declaration. Under both profiles the stride may not exceed 255 bytes, a declaration may not hold more than 16 elements and usage indices must be 0–15. Reach admits the element formats Single through NormalizedShort4; HiDef adds HalfVector2 and HalfVector4. The legacy empty declaration is exempt.
The declaration always reaches the renderer
IVertexBufferRenderer::SetVertexDeclaration is a pure virtual method: every renderer must implement it, and the buffer calls it at creation and on every raw upload. A description that says CNA selects vertex layouts from the stride alone is obsolete. Stride is still visible because renderers optimise their known built-in layouts, but faithful paths bind the declared elements: Vulkan refuses a multi-stream declaration that does not supply every input of the chosen stock program before the draw is queued (an incomplete single-stream declaration instead falls back to its stride-inferred route, behind RequireFaithfulDeclarationEXT, which refuses only what that route would read from the wrong bytes), Software reads declared streams by semantic and falls back to its recognised stride list only for a buffer with no declaration, and the GL renderers bind a custom program's attributes by element index.
VertexElement is mutable, and its hash is weak
VertexElement has setters for its offset, format, usage and usage index; changing an element after building a declaration does not change the declaration, which holds its own copy. GetHashCode() (VertexElement.cpp) hashes the four 32-bit words as XNA hashes a value type — by XOR, with zero mapped to INT32_MAX — so different elements collide easily (swapping two fields, for instance). Do not use the hash as a declaration identity: when building a cache key compare the complete element sequence (operator==) and the stride.
Buffers, index widths and dynamic updates
VertexBuffer stores a declaration, a vertex count, a BufferUsage and a renderer-owned resource. Copying is deleted and moving is noexcept, so a native buffer handle is never duplicated. IndexBuffer has 16-bit (std::uint16_t) and 32-bit (std::uint32_t) SetData/GetData families plus a byte-exact SetData<T> template. Both buffer types are limited to 67,108,863 bytes, and the Reach profile refuses a 32-bit index buffer (and 32-bit user indices) with NotSupportedException.
IndexElementSize uses XNA's ordinals (SixteenBits = 0, ThirtyTwoBits = 1), but content formats encode the width in other ways — the XNB model reader, for example, decodes an element size in bytes and maps 2 to SixteenBits. Tools that move index data between formats must translate the meaning (16 or 32 bits), never copy an ordinal or a Boolean flag.
DynamicVertexBuffer and DynamicIndexBuffer add SetDataOptions overloads and XNA's content-lost surface. The typed (data, startIndex, elementCount, options) overloads always write from the buffer's own beginning — startIndex selects where reading from the source array begins — while the XNA-shaped offsetInBytes overloads write at a byte offset. Most renderers treat Discard and NoOverwrite as real mapping hints (orphaning or an unsynchronised write); a few treat every call like Discard. getIsContentLostProperty() becomes true only when a renderer reports a real device reset — the DIRECTX9, DIRECTX11, DIRECTX12, DIRECT2D and WEBGPU renderers emit that event — and is cleared by the next SetData; on renderers whose API cannot lose a device it stays false. CPU shadows, readback and the rule that a bound buffer cannot be rewritten without Discard/NoOverwrite are covered on Vertex and index buffers.
Bindings carry an offset and an input rate
VertexBufferBinding (VertexBufferBinding.cpp) combines a buffer, a VertexOffset and an InstanceFrequency. Frequency 0 means per-vertex input; a positive frequency means per-instance input advancing once every that many instances. The constructor throws ArgumentNullException for a null buffer and ArgumentOutOfRangeException for an offset outside [0, VertexCount) or a negative frequency.
GraphicsDevice::SetVertexBuffer(buffer, offset) replaces all bindings with one per-vertex binding; a null buffer clears them. SetVertexBuffers (GraphicsDevice.cpp) accepts at most 16 bindings (NotSupportedException "Max Vertex Buffers supported is 16.", XNA's limit under both Reach and HiDef) and refuses a null binding with ArgumentException. That follows Microsoft XNA rather than FNA: FNA accepts null entries, which then fail during vertex fetch. The bindings are applied in order, and like XNA's finally block a validation failure keeps the prefix processed before it; binding 0's buffer becomes the current vertex buffer. Sixteen accepted bindings are an API ceiling, not proof that the selected renderer can consume sixteen active streams.
When several streams need a capability
Two shapes need no capability, because every renderer has always supported them: one per-vertex stream, and one per-vertex stream plus one per-instance stream. More than one stream of the same input rate — a declaration split across several buffers, or several per-instance streams — requires GraphicsCapability::MultiStreamVertexInput. ValidateVertexStreamCapability throws NotSupportedException naming the per-vertex and per-instance stream counts when the renderer reports false, and a second NotSupportedException when either count exceeds the renderer's GetMaxVertexStreams() (16 by default; VULKAN reports its device's vertex-input binding limit, and WEBGPU reports one fewer than its device's maxVertexBuffers because one slot is reserved for a neutral record, which is 7 on a device with the default limit of 8). The list is never silently truncated: without this gate a renderer that derives its input layout from one stride would render from stream 0 alone. Which renderers report the capability is in the matrix on Renderers: capability matrix.
Streams compose by semantic
FillVertexStreamBindings turns the bound set into the stream table of GpuDrawParams (at most 16 entries): slot, renderer buffer, declaration stride, instance frequency, capacity and element offset for each non-null binding. Streams compose by (VertexElementUsage, usage index), not by slot order. The device walks every element in binding order with one usage table shared by per-vertex and per-instance streams, and a (usage, index) pair already claimed by an earlier stream is moved to the first free index of the same usage — the rule FNA3D's native drivers apply. Every stream is kept: even a declaration whose pairs all collide becomes usage indices 1–15 and can still be consumed, by a compiled effect for instance. Only when all sixteen indices of one usage are taken does the draw throw NotSupportedException. Inside a single declaration a repeated pair is already rejected at construction. Split declarations should therefore use distinct (usage, index) pairs on purpose, and code must not assume that a stream's usage indices reach the shader unchanged.
The capacity recorded for each stream is the buffer's VertexCount — the size it was created with — not the length of the last SetData, because XNA lets a game fill a large buffer once and rewrite a prefix each frame. A per-vertex stream is marked as consumed only when the stock program reads one of its (remapped) semantics; per-instance streams and legacy empty-declaration buffers are always consumed. Range checks apply only to consumed streams.
The minimum-offset fold
VertexBufferBinding::VertexOffset, vertexStart and baseVertex are all element counts, and each renderer converts elements to bytes with each stream's own stride. With several per-vertex bindings, FoldedVertexStreamOffset() takes the smallest offset among them, folds it once into the draw's vertexStart (non-indexed) or baseVertex (indexed) with a saturating add, and leaves each stream only its non-negative remainder. Because renderers already multiply the shared element base by each stream's own stride, the common part is applied exactly once without requiring equal strides.
The two obvious alternatives are both wrong. Folding each stream's full offset into one global base applies the smallest offset several times; dropping the remainders aligns only the earliest stream. A per-instance stream keeps its whole offset, because baseVertex never advances it; the instanced route validates each consumed per-instance stream separately — 1 + lastInstance / frequency records must fit after its offset. With one stream the fold is byte-identical to the older single-offset behaviour. The normalisation lives in the shared layer so that every renderer sees the same binding geometry; OrdinaryDrawBindingOffsetTests.cpp and OrdinaryDrawMultiStreamTests.cpp pin it.
Draw validation has a deliberate order
GraphicsDevice::DrawIndexedPrimitives checks, in order:
- the device is not disposed (a device without a renderer returns silently);
- the renderer's 3D policy —
Ensure3DSupported, which throws on a 2D-only renderer under the default policy, before any argument is examined; numVerticesandprimitiveCountare positive, the profile's primitive limit (65,535 per draw onReach, 1,048,575 onHiDef) and the index count derived from the topology;- an applied effect, a bound index buffer and a bound vertex buffer — each missing one is an
InvalidOperationException; a disposed bound buffer is anObjectDisposedException; - the effect's matrices and draw packet, the folded offsets and the stream table;
- ranges: the consumed indices inside the index buffer and the declared vertex window inside each consumed stream;
- the multi-stream capability and stream count;
- draw-time profile rules on sampler and blend state, then the renderer call.
Range validation precedes capability validation, so an out-of-range request is an argument error before it is a capability error. Step 6 is conditional, though: XNA forwards these values to the native API, and CNA keeps its managed range guard only where the renderer asks for it (RequiresManagedBufferedDrawRangeValidationEXT(), true by default because most families stage draw input through CPU copies). EasyGL, OPENGL4 and SOFTWARE opt out and forward ranges as XNA does; the legacy empty-declaration route is always checked because its renderer would otherwise form raw host pointers. The same public call can therefore throw ArgumentOutOfRangeException on one renderer and hand the range to the driver on another.
User-array draws follow the same stages
The DrawUserPrimitives and DrawUserIndexedPrimitives overloads establish a declaration, validate counts, offsets and the profile, require an applied effect, pack typed values into the built-in stream form, upload them to a transient renderer buffer that carries the type's canonical declaration (as FNA uses its per-type declaration cache — a compiled effect needs real semantics, not a stride), fill the draw packet from the applied effect and enter the renderer. The VertexPositionColor overload also forces vertex colour on in the packet.
Evidence and limits
Checked by reading the vertex types, declaration, buffer and binding classes and the shared draw routes at snapshot 009d40f5; nothing was built or executed. The binding-offset and multi-stream contracts have unit tests in the graphics module (OrdinaryDrawBindingOffsetTests.cpp, OrdinaryDrawMultiStreamTests.cpp, DrawRouteValidationTests.cpp), run against whatever renderer a build compiles in. The XNA and FNA comparisons (null bindings, prefix retention, usage-index remapping) are the behaviours CNA's code comments cite; they were not re-run against XNA for this page.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Internals
- Indexed draw trace · Graphics resources internals
- Tests and validation
- Test architecture