Indexed draw trace

CNA snapshot 009d40f5  ·  Development › Graphics internals  ·  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. The Vulkan and Software paths were re-read at this snapshot; the listed tests exist but none was run for this page.

The public call is the same on every renderer; the GPU boundary is not. This trace follows GraphicsDevice::DrawIndexedPrimitives through its shared validation into two contrasting families: Vulkan, which snapshots the draw and replays it at Present, and Software, which rasterises into a CPU framebuffer before the call returns. A maintainer who knows where the shared layer ends can tell a bug in the neutral contract from a bug in one translation layer.

Call and submission paths

Game::Draw  (an effect pass applied, vertex and index buffers bound)
  → GraphicsDevice::DrawIndexedPrimitives(type, baseVertex, minVertexIndex, numVertices,
                                          startIndex, primitiveCount)
      → public validation, effect snapshot into GpuDrawParams, offset folding, stream table
      → applySamplerStatesToRenderer()  → validateDrawState(), rasterizer + sampler state
      → IGraphicsRenderer::DrawIndexedPrimitivesEx(vb, ib, world, view, projection,
                                                   type, primitiveCount, params)
          ├─ VulkanRenderer: copy bytes + state into a Pending3DDraw → pending3D_
          │     … GraphicsDevice::Present → VulkanRenderer::Present → SubmitFrame(false)
          │       → fence wait → retire → vkAcquireNextImageKHR → RecordCommandBuffer
          │       → vkQueueSubmit → vkQueuePresentKHR
          └─ SoftwareRenderer: DrawIndexedPrimitivesInternal → decode indices → transform,
                clip, rasterise into CurrentFramebuffer()
                … GraphicsDevice::Present → SoftwareRenderer::Present
                  → no-op off-screen, or an RGBA8 frame to the platform surface presenter
      → CNA_RECORD_GRAPHICS_DRAW (diagnostic counter, only after the renderer returns)

This split is a lifetime rule. A family that defers work must not keep unprotected pointers into caller-owned mutable vertex, index or effect state, because the game can change or destroy them before submission. A family that rasterises immediately still needs stable state for the duration of the call, but its submission boundary is the call itself.

Step 1: the neutral public method

GraphicsDevice::DrawIndexedPrimitives is the only shared validation on this route. In order:

  1. ThrowIfDisposed(); return silently if there is no renderer; renderer_->Ensure3DSupported("GraphicsDevice::DrawIndexedPrimitives"), which a 2D-only family uses to throw or, with WarnAndStub, to warn.
  2. numVertices and primitiveCount must be positive; ValidateProfilePrimitiveCount() applies the profile's primitive limit; CheckedPrimitiveElementCount() derives the consumed index count from the topology.
  3. No applied effect, no index buffer or no vertex buffer is an InvalidOperationException; a bound but disposed buffer is an ObjectDisposedException.
  4. ExtractMatrices() reads world, view and projection from the applied effect; Effect::FillGpuDrawParams() captures the effect's parameters (textures, lighting, fog, alpha test, skinning, PBR, a compiled-effect runtime or custom-shader request) into GpuDrawParams.
  5. Offsets: startIndex is copied unchanged; params.baseVertex is baseVertex plus FoldedVertexStreamOffset(), added with a saturating helper so the fold itself cannot overflow.
  6. FillVertexStreamBindings() describes every bound stream (see below).
  7. Range checks, only when there is no stream table (the legacy empty-declaration case) or the renderer answers true to RequiresManagedBufferedDrawRangeValidationEXT(): non-negative startIndex and minVertexIndex, the consumed indices inside the index buffer, and the declared vertex window inside each shader-consumed stream.
  8. ValidateVertexStreamCapability(): more than one stream of the same input rate needs MultiStreamVertexInput and at most GetMaxVertexStreams() streams; the binding list is never silently truncated.
  9. applySamplerStatesToRenderer(): runs validateDrawState() — XNA's draw-time profile rules: no filtered sampling of point-filter-only float formats (except inside the CNAEXT engine layer's float-filtering scope, when the renderer can filter them), clamp addressing for non-power-of-two textures under Reach, no blending into non-blendable render-target formats — then pushes rasterizer state and every sampler slot's filter, address, anisotropy and mip state.
  10. renderer_->DrawIndexedPrimitivesEx(...); CNA_RECORD_GRAPHICS_DRAW(true, primitiveCount) runs only if that returns.

Element offsets, not byte offsets

startIndex is an index-element offset. baseVertex and each VertexBufferBinding.VertexOffset are vertex-element offsets. FoldedVertexStreamOffset() takes the smallest VertexOffset among the bound per-vertex streams and folds it into baseVertex once; each per-vertex stream keeps the remainder as its own vertexOffset, and a per-instance stream keeps its whole offset because baseVertex never advances it. The renderer converts elements to bytes with each stream's own declaration stride. Multiplying by a stride in the neutral layer, or shifting startIndex by a vertex offset, applies the wrong unit — most visibly with multi-stream geometry.

Each GpuVertexStreamBinding carries the slot, the stream's renderer buffer, its stride, its instance frequency, its capacity (VertexBuffer::VertexCount, not the length of the last SetData, because XNA lets a game rewrite a prefix of a larger buffer), the element offset, and per-element effective usage indices: a repeated (usage, usageIndex) pair across streams is moved to the next free index of that usage, as FNA3D's drivers do. vertexShaderInputUsed marks streams the stock program actually reads; ranges are validated only for those. A single per-vertex buffer whose declaration is empty (the CNAEXT VertexBuffer(device, count) convenience form) is deliberately left as vertexStreamCount == 0, so the renderer uses its own upload stride.

RequiresManagedBufferedDrawRangeValidationEXT()FamiliesConsequence
false (override)EasyGL, OpenGL4, SoftwareRanges are forwarded as XNA forwards them to the native API; the family promises it cannot read outside host memory (GL never reads host memory for them; Software defaults missing records).
true (the IGraphicsRenderer default)every other family, including VulkanThe device keeps its compatibility range guard, because these families stage draw input through CPU copies.

This method does not issue a Vulkan command, acquire a swapchain image or know a Direct3D input layout. Those are deliberate abstraction boundaries.

Step 2A: Vulkan snapshots, then replays

VulkanRenderer::DrawIndexedPrimitivesEx casts to its own vertex and index buffer renderers and computes the index width (2 or 4 bytes), the consumed index count and a pointer to the selected span of the CPU-mapped index data.

  • Custom shader route. If the effect requested a custom ShaderEffect program, the draw goes to QueueCustomEffect3DDrawEXT, which builds its own immutable stream snapshots, and the method returns.
  • Multi-stream packing. With several per-vertex streams, VulkanIndexedStreamWindow finds the vertex window the selected indices reach and PackVulkanStreamsEXT interleaves exactly that window into one record, with a combined declaration and a rebased native base vertex.
  • Program family. Stock program families (alpha test, dual texture, environment map, skinned, PBR and skinned PBR, lit textured/untextured/coloured, the BasicEffect shapes) are chosen from the effect parameters and the vertex declaration, not from the stride alone. A multi-stream declaration that does not supply every input of the chosen program is refused here (NotSupportedException for the packed record) rather than letting pipeline creation fail later at Present; an incomplete single-stream declaration falls back to the stride-inferred layout behind RequireFaithfulDeclarationEXT, which refuses only what that layout would read from the wrong bytes. Stride lists for PBR and skinned buffers and the bone-index format check apply only when no complete declaration exists.
  • Sources and matrices. NoteSampledSourcesEXT records the render targets this draw samples, so a mid-frame readback flush can replay their producing passes first. The world-view-projection matrix is world * view * projection * XnaPixelCenterCorrectionEXT(primitive), the Direct3D 9 pixel-centre convention post-multiplied in row-vector order.
  • Compiled effects. With CNA_VULKAN_COMPILED_EFFECTS the compiled program replaces the stock selection (PrepareCompiledEffectDrawEXT); without it a compiled-effect draw throws NotSupportedException instead of silently using a stock shader.

The method then fills a Pending3DDraw (declared in VulkanRenderer.hpp): the vertex bytes (the whole bound buffer, vertexCount × stride, or the packed window), the base vertex, only the selected index bytes, topology, draw count, index type, depth, stencil, blend, cull, wireframe and depth-bias state, the destination render target, the stride, the family flags and push constants, and the vertex layout taken at draw time. PushPending3DDraw() adds what must also be frozen at enqueue time — the active occlusion query, scissor, viewport, blend factor, the bind-cycle segment the draw belongs to and its position in the frame's command order — and appends the record to pending3D_. The snapshot exists because buffers, bindings and effect passes may change before the frame is recorded: a new field that affects replay must be captured here; reading a mutable renderer field only in RecordCommandBuffer gives earlier draws the last draw's state.

Present and SubmitFrame

VulkanRenderer::Present() calls SubmitFrame(false):

  1. Wait on the current frame slot's fence (a lost device is detected here, at acquire, at submit and at present by CheckDeviceLostEXT, which raises the device-lost event once and throws).
  2. ProcessRetiredResources(false): free retired handles and MRT proxies whose generation is more than MaxFramesInFlight (2) frames old.
  3. vkAcquireNextImageKHR; on VK_ERROR_OUT_OF_DATE_KHR recreate the swapchain and return without resetting the fence, so a failed acquire cannot leave the slot's fence unsignalled forever.
  4. Reset the fence and command buffer, RecordCommandBuffer() replays the pending passes, vkQueueSubmit with the image-available and render-finished semaphores, advance the retirement generation.
  5. vkQueuePresentKHR; out-of-date or suboptimal recreates the swapchain; the frame slot advances modulo MaxFramesInFlight.

A draw returning to game code is therefore not evidence that the GPU executed it; the queue and fence are the execution boundary. Backbuffer readback takes a separate route: ReadBackbuffer() calls SubmitFrame(true), which records a copy into a staging buffer, waits for the fence and holds the image; the pixels are read and only then FinishDeferredPresent() presents.

Step 2B: Software draws now

SoftwareRenderer::DrawIndexedPrimitivesEx calls DrawIndexedPrimitivesInternal(..., false) (the instanced route calls it once per instance with adjusted per-instance offsets).

  1. Reject primitiveCount <= 0 and any topology other than TriangleList, TriangleStrip, LineList, LineStrip and PointListEXT.
  2. A compiled-effect draw requires a build with CNA_SOFTWARE_COMPILED_EFFECTS (otherwise NotSupportedException); compiled point lists are not implemented.
  3. Declared streams are read by semantic. Only a buffer with no declaration falls back to the recognised stride set (16, 20, 24, 32, 48, 52, 56, 60, 68, 76, 80 bytes); any other stride without a declaration throws.
  4. Strict addressing validation (ValidateIndexedAddressing) runs only for the legacy no-stream-table or empty-declaration shapes, whose fallback would otherwise form raw host pointers. For declared streams the ranges pass through as in XNA, and an index or vertex record outside its buffer is skipped rather than read.
  5. Snapshot the draw state by value: depth, stencil, blend state and blend factor, the active viewport transform and the clip rectangle (framebuffer ∩ viewport ∩ scissor when enabled).
  6. For each primitive, decode the index at startIndex + local (16- or 32-bit), add baseVertex once, and resolve each consumed stream at stream.vertexOffset + vertexIndex with that stream's own stride (a per-instance stream at its own offset).
  7. Transform by world * view * projection, clip to the frustum, fan the clipped polygon, and rasterise into CurrentFramebuffer() — the bound render target or the backbuffer — or, for a compiled effect, into every bound MRT framebuffer.

There is no Vulkan-style Pending3DDraw: when the method returns, the pixels are written. SoftwareRenderer::Present() (in SoftwareRenderer2DState.cpp) is a no-op without a surface presenter; with one (Terminal on a TTY) it resolves MSAA and hands the backbuffer — never a still-bound render target — as an RGBA8 frame to the presenter. In the GDI build the same sources are compiled with CNA_SOFTWARE_2D_ONLY, where this method throws NotSupportedException.

A bug common to both families usually belongs in the public offset or state construction; a bug in only one is more likely declaration interpretation, program mapping, or raster and submission specifics.

Other families

Every family receives the same call; this page traces only two. Where the other implementations live, for a maintainer who needs to follow the same draw elsewhere:

FamilyDrawIndexedPrimitivesExWhere it is traced
EasyGLEasyGLRenderer.cppEasyGL renderer internals
OpenGL4OpenGL4StockDraw.cppOpenGL4 renderer internals
SDL_gpuSdlGpuRenderer.cppSDL_gpu renderer internals
HeadlessHeadlessRenderer.cpp: checks the effect inputs it was given and rasterises nothingHeadless renderer internals
Stubno override; the base implementation forwards to DrawIndexedColoredPrimitives, which Stub implements as a no-opStub renderer internals
WebGPU, DirectX 9/11/12, FNA3D, Metal, PortableGLWebGPURenderer.cpp, D3D9EffectDraw.cpp, DirectX11Renderer.cpp, DirectX12Renderer.cpp, Fna3dDraw.cpp, MetalRenderer.mm, PortableGLRenderer.cppNot traced in the Development area yet.
2D-only familiesHTML DOM and SVG DOM throw in Ensure3DSupported; Direct2D routes it through HandleUnsupported3DCall (throw by default, a warning under WarnAndStub); GDI's DrawIndexedPrimitivesEx throws through ThrowUnsupportedFeature; the others reach the base implementation or their own unsupported-3D handlingRenderers: 2D-only renderers

Where failures appear

SymptomInspect first
Immediate public exceptionEffect and buffer binding, disposed buffers, profile primitive limit, the range checks the renderer asked for, validateDrawState() profile rules, stream capability.
Wrong geometry on every familyGpuDrawParams construction, element-versus-byte arithmetic, FoldedVertexStreamOffset(), usage-index remapping, matrix extraction.
Vulkan throws at draw timeThe declaration guard: the chosen stock program needs an input the declaration does not supply.
Vulkan fails only at PresentDeferred record completeness, RecordCommandBuffer, pipeline or descriptor creation, the submit result, validation-layer output.
Vulkan shows a later draw's state on earlier drawsA field read at record time instead of captured in Pending3DDraw or PushPending3DDraw.
Software differs from VulkanSoftware's declaration or stride fallback, index decoder, viewport and scissor clip, raster conventions, versus Vulkan's program family and input layout.
GPU appears to hangFence wait, acquire, queue submit and present; distinguish a stalled GPU from an application loop that never reaches Present.

Historical case (fixed). An earlier Software renderer read indices from element zero in both indexed raster loops and fetched vbBase + index * stride with no base addend, so startIndex and a positive baseVertex were silently discarded and every draw rendered the buffer prefix, while the GPU families were already correct. The current loop adds both exactly once, and software_indexed_addressing_test.cpp (Software_IndexedAddressing) pins the contract: element offsets, a single baseVertex addend, topology-derived counts, and minVertexIndex/numVertices treated as hints only. It is the pattern to expect when one family disagrees: the neutral layer was right and one translation ignored a field.

Safe change path and evidence

Write or select a small indexed-draw fixture that fixes geometry, effect, declaration, offsets and expected pixels. Run the neutral tests first, then renderer conformance for the changed family and for an independent one. For Vulkan include a multi-frame case — a one-frame screenshot will not expose stale deferred state or resource retirement — and keep validation on: debug builds enable VK_LAYER_KHRONOS_validation when it is installed, and in any configuration that compiles VULKAN, TestHelpers.cmake makes every renderer test fail on a [Vulkan Validation] line (the exemption list is empty). For Software, test both declaration-driven and empty-declaration buffers.

LevelTests present at this snapshot
Neutral (CnaGraphicsTests, run against the compiled-in renderer)GraphicsDeviceValidationTests.cpp, DrawRouteValidationTests.cpp, IndexedDrawDeferredTests.cpp (start index, base vertex, 32-bit indices, range hints), OrdinaryDrawBindingOffsetTests.cpp and OrdinaryDrawMultiStreamTests.cpp (the element-offset and multi-stream contract), GraphicsProfileDrawLimitTests.cpp
Vulkan (registered in vulkan/examples)Vulkan_IndexedDrawRange, Vulkan_DrawRangeValidation, Vulkan_DeferredResourceLifetime, Vulkan_DeferredSourceLifetime, Vulkan_Deferred_Scissor, Vulkan_Deferred_Viewport, Vulkan_Swapchain_Sync, Vulkan_SwapchainOutOfDate, Vulkan_SwapchainChurn, Vulkan_DeviceLostContract, Vulkan_RenderTarget_ProducerConsumer_SyncVal; unit tests under vulkan/tests cover compiled effects
Software (registered in software/examples)Software_IndexedAddressing, Software_DrawRangeValidation, Software_DrawNoIndexBuffer, Software_VertexDeclaration, Software_PresentLifecycle; unit tests under software/tests cover presentation and compiled-effect conformance

These are present in the source; none was executed for this page, and which host and CI job runs each is recorded on Test architecture and Verification: CI.

Read in execution order

  1. GraphicsDevice.cpp: DrawIndexedPrimitives, FoldedVertexStreamOffset, FillVertexStreamBindings, ValidateVertexStreamRanges, ValidateVertexStreamCapability, applySamplerStatesToRenderer and validateDrawState — the only shared validation and the exact delegate call.
  2. IGraphicsRenderer.hpp in the renderer-contract headers (CNA/Internal/Renderers): GpuVertexStreamBinding, GpuDrawParams, DrawIndexedPrimitivesEx and RequiresManagedBufferedDrawRangeValidationEXT — what can cross the boundary.
  3. VulkanRenderer.cpp: DrawIndexedPrimitivesEx, PushPending3DDraw, SubmitFrame, RecordCommandBuffer, ProcessRetiredResources and Present, in that causal order.
  4. SoftwareRenderer.cpp: DrawIndexedPrimitivesInternal and its immediate framebuffer writes; then Present in SoftwareRenderer2DState.cpp.
  5. vulkan_deferred_resource_lifetime_test.cpp and software_indexed_addressing_test.cpp: pick the fixture that establishes the semantics you change, not merely a test with “draw” in its name.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.