Vertex and index buffers: CPU shadows, SetDataOptions and layouts

CNA snapshot 009d40f5  ·  Deep Dives › The graphics machine  ·  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; no test was run. The EasyGL tests named prove EasyGL behaviour only.

Vertex and index buffers look like thin wrappers around GPU memory, but at this snapshot every upload goes through a CPU shadow that CNA composes and then submits whole, every read comes from that shadow, and the SetDataOptions hint means something different on each renderer family. This page states those rules, how vertex layouts reach a renderer (by stride, and by declaration), and what the tests prove. The user-level introduction is 3D rendering: buffer management and Tutorial 38.

The public types

VertexBuffer and IndexBuffer (VertexBuffer.cpp, IndexBuffer.cpp) derive from GraphicsResource and are move-only; DynamicVertexBuffer and DynamicIndexBuffer derive from them. Construction validates before the renderer is asked for storage: a vertex count must be positive, the vertex declaration must be legal for the profile, and the buffer may not exceed 67,108,863 bytes, the same limit at both profiles. It then remembers the VertexDeclaration, zero-fills a CPU shadow of the full capacity and uploads it, so a new buffer reads back as zeros rather than as whatever the allocation held.

Typed transfers exist for the four stock XNA vertex types (VertexPositionColor, VertexPositionColorTexture, VertexPositionNormalTexture, VertexPositionTexture) and three CNAEXT ones (VertexPositionNormalTextureSkinned, VertexPositionNormalTangentTexture, VertexPositionNormalTangentTextureSkinned), each with (data, count) and (data, startIndex, elementCount) forms; a template accepts any other vertex type; and XNA's offset form SetData(offsetInBytes, data, startIndex, elementCount, vertexStride) and its GetData counterpart write or read a window that must start on a vertex boundary. The CNAEXT raw forms SetDataRaw(data, count, stride) and SetDataRawAtEXT(offsetInBytes, …) take bytes; their stride must equal the declaration's, and every declared element must lie inside it. IndexBuffer transfers 16- or 32-bit indices; 32-bit index buffers are refused under Reach.

One CPU shadow, submitted whole

Each buffer keeps a private byte vector, cpuShadow_, of its full logical capacity. It serves two purposes:

  • Reads. GetData copies from the shadow and never asks the renderer. Nothing in CNA's own pipeline writes GPU-side data back into a vertex or index buffer, so a shadow is a faithful implementation of read-your-own-writes. A buffer created with BufferUsage::WriteOnly refuses every GetData with NotSupportedException, as XNA does.
  • Writes. The renderer contract (IVertexBufferRenderer::SetData, SetData16/SetData32 for indices) replaces whole-buffer contents; it has no prefix or window update. So every public upload, whole, windowed or raw, is composed into the shadow first and the complete logical buffer is submitted. A short upload therefore keeps the rest of the buffer, as XNA's prefix semantics require, and a partial update saves CPU work but not upload bandwidth.

With SetDataOptions::Discard the bytes not named by the upload become undefined in XNA; CNA represents that state deterministically by zero-filling the shadow before copying the new data. Options that combine both flags are canonicalised with Discard winning. Every upload also clears a dynamic buffer's content-lost flag and pushes the buffer's declaration to the renderer immediately before the data (GFX-043), so the renderer always sees the layout that belongs to the bytes.

The options path shares the same shadow

The options-taking overloads of the dynamic buffers go through the same single upload function (VertexBuffer::UploadValidatedData; the index buffer's counterpart has the same shape) as the ordinary ones, and differ only in calling the renderer's SetDataWithOptions instead of SetData. A GetData after an options-taking SetData therefore returns the bytes just written. An earlier revision updated the shadow only on the ordinary path, so a fresh DynamicVertexBuffer or DynamicIndexBuffer with BufferUsage::None threw ArgumentOutOfRangeException from GetData after a Discard upload; routing both through one function removed that class of divergence.

Dynamic buffers

DynamicVertexBuffer and DynamicIndexBuffer add three things: the SetData(…, SetDataOptions) overloads, a real getIsContentLostProperty() with its ContentLost event, and permission to write while bound. An ordinary SetData on a buffer currently bound to the device throws InvalidOperationException ("The vertex buffer resource is in use."); an upload with Discard or NoOverwrite is legal while bound, because those options promise not to disturb data the GPU may still read.

The storage itself is not different. The protected VertexBuffer/IndexBuffer constructors receive a dynamic Boolean from the derived classes but leave the parameter unnamed and never pass it to a renderer factory, so choosing a Dynamic* class does not select different storage. The content-lost flag is set when a renderer reports a real device reset and cleared by the next upload. The options overloads that take no offset write from the start of the buffer, with startIndex selecting only where reading from the source array begins; XNA's offsetInBytes form is available for writing a window elsewhere.

What each family does with the hint

The interface default of SetDataWithOptions drops the option and calls plain SetData. At this snapshot:

FamilyDiscardNoOverwriteNotes
EasyGLOrphans the buffer (a glBufferData with no data at full capacity), then a sub-data uploadIn-place sub-data upload once storage existsThe index path decides "storage exists" from its retained CPU copy, which it keeps only while the context-recovery registry is alive; with recovery disabled a NoOverwrite index upload falls back to a fresh allocation. Plain uploads size the GL buffer by capacity, not by the upload, so a short upload cannot shrink the storage later draws read (FX-131).
SDL_GPUSDL's cycle flag is true for None and Discard, false for NoOverwrite
Direct3D 9D3DLOCK_DISCARDD3DLOCK_NOOVERWRITEOrdinary and dynamic buffers are both created D3DUSAGE_DYNAMIC in D3DPOOL_DEFAULT; a fresh allocation always discards
Direct3D 11D3D11_MAP_WRITE_DISCARDD3D11_MAP_WRITE_NO_OVERWRITENone also maps with discard
Direct3D 12All three options take the same path: a fresh allocation from a per-frame upload ring plus a copyThe source argues that a fresh ring allocation is Discard and that the bump allocator is NoOverwrite by construction, because no in-flight range is reused before the frame fence
OpenGL 4, FNA3DForwarded to the family's native upload
VulkanNo override: the interface default applies
WebGPU, SoftwareAccepted and ignored before a queue write or a CPU copy
Headless, Stub, 2D-only familiesHeadless validates and traces; the 2D-only families refuse vertex-buffer construction under the default Unsupported3DGraphicsCallBehavior::Throw policy (with WarnAndStub they substitute a no-op buffer), so no hint ever matters

The EasyGL test easygl_dynamic_buffer_stress_test.cpp (EasyGL_DynamicBufferStress) exercises the distinction through the public classes: two warm-up frames and 60 measured frames cycle None, Discard and NoOverwrite; the first frame uploads two different dynamic vertex buffers with Discard and NoOverwrite before either draw reaches a readback and checks their left and right colours independently; later frames draw a full-screen buffer with each option and verify the visible colour, and update a six-index DynamicIndexBuffer the same way. That is pixel proof that all three routes keep the current data through repeated use. It is not proof of which native primitive ran; the orphan and sub-data distinction is established by reading the implementation.

Vertex layouts: by stride, and by declaration

A renderer has two ways to know how to read a vertex. The historical one is the stride: the stock effects' shader routes are selected from the uploaded byte stride, one stock or CNA vertex type per stride. The set has grown well beyond XNA's four types (16, 20, 24 and 32 bytes): Vulkan's pipeline key, for example, distinguishes eleven strides at this snapshot, adding 48 (PBR tangent layout), 52 (GPU skinning), 56 (skinned plus vertex colour), 60 (dual-UV PBR), 68 (tangent-space PBR plus skinning), 76 (dual-UV skinned PBR) and 80 (skinned PBR plus colour). The exact set is renderer-specific, and recognising a stride is not a promise that every effect can consume it.

The second way is the declaration itself. IVertexBufferRenderer::SetVertexDeclaration is a pure virtual at this snapshot, so every family must say what it does with the element list the public buffer pushes before each upload. The EasyGL, OpenGL 4, PortableGL, Vulkan, WebGPU, SDL_GPU, Software and Direct3D 11/12 renderers remember it, Direct3D 9 builds a native vertex declaration from it, FNA3D binds a real per-stream declaration, and Headless and Stub ignore it. What a family then does with a remembered declaration differs: EasyGL, when a declaration is present, binds attributes generically from it instead of from the stride switch, maps all twelve VertexElementFormat values to GL attribute shapes (keeping the integer-versus-float distinction the skinned layout needed), and assigns GLSL attribute locations by element order, 0, 1, 2 and so on; VertexElementUsage does not choose the location.

The EasyGL test easygl_shadereffect_custom_vertex_layout_test.cpp (EasyGL_ShaderEffect_CustomVertexLayout) proves that route with a 48-byte record of five elements: position, normal and tangent as Vector3, a Vector2 texture coordinate and a trailing normalised Color. Forty-eight bytes is also the size of CNA's fixed PBR layout, which uses a four-float tangent and has no colour; the test encodes each input into the output pixel, so its readbacks distinguish "followed the declaration's offsets" from "selected the fixed 48-byte case". For any other family, treat an arbitrary VertexDeclaration with a custom ShaderEffect as a claim to test on that family.

Evidence and its limits

Checked by reading the CNA source at 009d40f5; not executed. The shadow, options path, in-use rule and dynamic-buffer semantics are shared code; the per-family option handling and declaration handling were read from each family's buffer renderer. The two EasyGL tests named above are registered CTests; neither was run for this page, and neither proves behaviour on another family.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Maintainer workflow
Fix a renderer bug
Tests and validation
Test architecture