Indexed draw trace

CNA snapshot 3b43bd86  ·  Development › Graphics internals  ·  source links pinned to 3b43bd86

✓

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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. The SDL_GPU 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: SDL_GPU, 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)
          ├─ SdlGpuRenderer: DispatchStockDrawEXT → Queue*Draw: streams + state by value
          │     → typed command queue, PushDrawOrder → drawOrder_
          │     … GraphicsDevice::Present → SdlGpuRenderer::Present → EnsureFrameRendered
          │       → command buffer → SDL_WaitAndAcquireGPUSwapchainTexture → copy pass
          │       → one render pass per segment → RenderQueuedDraws → SDL_SubmitGPUCommandBuffer
          └─ 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 renderer without 3D support 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, 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, SDL_GPU, SoftwareRanges are forwarded as XNA forwards them to the native API; the family promises it cannot read outside host memory (GL and SDL_gpu draw from GPU buffers, never host memory; Software defaults missing records).
true (the IGraphicsRenderer default)every other family (WebGPU, DirectX 9, FNA3D, Headless, Stub)The device keeps its compatibility range guard, because these families stage draw input through CPU copies.

This method does not open a GPU pass, acquire a swapchain image or know a native input layout. Those are deliberate abstraction boundaries.

Step 2A: SDL_GPU queues, then replays

SdlGpuRenderer::DrawIndexedPrimitivesEx first asks BufferedDrawWindowIsServableEXT whether the selected index window lies inside the index buffer; a window it cannot serve draws nothing. A compiled-effect draw goes to QueueCompiledEffectDraw (only in a build with CNA_SDL_GPU_COMPILED_EFFECTS); everything else goes to DispatchStockDrawEXT.

  • Custom shader route. A valid custom ShaderEffect program goes to QueueCustomEffect3DDrawEXT, which captures every bound stream and the effect's own uniform block, and the method returns.
  • Shape selection. CollectStockVertexStreamsEXT gathers the bound streams and SelectStockVertexShapeEXT chooses one stock shape from the declarations and the effect parameters: colored, textured, colored-textured, lit, alpha test, dual texture or environment map. SkinnedEffect and PbrEffect are selected by effect state (QueueSkinnedDraw, QueuePbrDraw, a PBR draw needing exactly one per-vertex stream); a record that matches no stock program throws NotSupportedException at draw time, behind RequireFaithfulDeclarationEXT, rather than failing later at Present.
  • Snapshot. Each Queue*Draw builds a typed command by value: the vertex streams (CaptureStockVertexStreamsEXT, binding the buffer's shared SdlGpuBufferStorageEXT in place where the bytes are unchanged), the indices and the indexed range, topology, depth state, CaptureRenderState() (blend, colour write masks, cull, wireframe, depth bias, stencil), the uniforms with the world-view-projection matrix ApplyXnaPixelCenter(world * view * projection), the fog block, the sampled textures with their keepAlive references and the slot's sampler state, and the destination target. PushDrawOrder() then records the command's kind and index together with the pass segment, viewport, scissor and blend factor in force, and the frame is marked pending. On a multisampled target it first applies MultiSampleMask all-or-none, because SDL_gpu reserves its sample mask: a mask keeping every sample draws, one keeping none drops the draw, and any other throws NotSupportedException.

The snapshot exists because buffers, bindings and effect passes may change before the frame is recorded: a SetData on a buffer whose storage a queued draw still holds moves the buffer to fresh storage, so the draw replays the bytes it saw. A new field that affects replay must be captured at enqueue time; reading a mutable renderer field only during replay gives earlier draws the last draw's state. The queue, the command types and the pass segments are described in full on SDL_gpu renderer internals.

Present and EnsureFrameRendered

SdlGpuRenderer::Present() only calls EnsureFrameRendered(true); SDL presents the acquired swapchain texture when the command buffer that acquired it is submitted:

  1. Acquire one SDL_GPUCommandBuffer (owned by a FrameCommandBufferOwner) and insert the frame's queued debug labels.
  2. Acquire the swapchain texture with SDL_WaitAndAcquireGPUSwapchainTexture, or use the proxy in headless mode or after a mid-frame flush. A null texture (a minimised window) is a documented non-error skip, but a command buffer that attempted acquisition is still submitted, on every exit.
  3. Upload the frame's staged vertex and index data in a copy pass before any render pass (SDL_gpu forbids a copy pass inside a render pass).
  4. Walk the pass segments in bind order, open one native render pass per segment and call RenderQueuedDraws in the captured order; blit the readable backbuffer proxy to the swapchain if it is active.
  5. Submit, then drain the pending texture and pipeline releases; on an exception the command buffer is finished for failure and the queued logical frame is kept for a retry.

A draw returning to game code is therefore not evidence that the GPU executed it; the submit is the execution boundary. Backbuffer readback takes the same flush: ReadBackbuffer() switches the frame to the readable proxy, calls EnsureFrameRendered() and then downloads the proxy through a transfer buffer.

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 queued command as on SDL_GPU: when the method returns, the pixels are written. SoftwareRenderer::Present() (in SoftwareRenderer2DState.cpp) is a no-op without a surface presenter; with one (only Terminal on a TTY gets one: GraphicsDevice creates a presenter for a CPU renderer only when the platform can present but offers no native window, so on SDL3 the family stays off-screen) it resolves MSAA and hands the backbuffer — never a still-bound render target — as an RGBA8 frame to the presenter. Software compiles one implementation; no build variant turns this method into a refusal.

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
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, FNA3DWebGPURenderer.cpp, D3D9EffectDraw.cpp, Fna3dDraw.cppNot traced in the Development area yet.

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.
SDL_GPU throws at draw timeShape selection: the chosen stock program needs an input the declaration does not supply, or a PBR draw has more than one per-vertex stream.
SDL_GPU fails only at PresentEnsureFrameRendered: the copy-pass upload, pipeline creation for the captured state, the pass-segment walk, the submit result, and validation output.
SDL_GPU shows a later draw's state on earlier drawsA field read at replay time instead of captured in the queued command or by PushDrawOrder.
Software differs from SDL_GPUSoftware's declaration or stride fallback, index decoder, viewport and scissor clip, raster conventions, versus SDL_GPU's shape selection and vertex layout.
GPU appears to hangSwapchain acquisition, the submit and SDL's own fencing; 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 SDL_GPU include a multi-frame case — a one-frame screenshot will not expose stale deferred state or delayed releases — and keep validation on: debug builds create the SDL GPU device in debug mode, and every test registered in sdl-gpu/examples/CMakeLists.txt fails on a validation error or warning line, a VUID- identifier, or the error and warning prefix of the debug layer under SDL's Direct3D 12 driver. 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
SDL_GPU (registered in sdl-gpu/examples)SdlGpu_DrawOrder, SdlGpu_DeferredSourceLifetime, SdlGpu_Deferred_Scissor, SdlGpu_Deferred_Viewport, SdlGpu_PassBoundary_Upload, SdlGpu_DrawLineTopology, SdlGpu_PresentLifecycle, SdlGpu_SwapchainRecovery, SdlGpu_MinimizedRetry; unit tests under sdl-gpu/tests cover resident geometry, texture lifetime and 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. SdlGpuRenderer.cpp: DrawIndexedPrimitivesEx, DispatchStockDrawEXT, one Queue*Draw, PushDrawOrder, EnsureFrameRendered and RenderQueuedDraws, in that causal order.
  4. SoftwareRenderer.cpp: DrawIndexedPrimitivesInternal and its immediate framebuffer writes; then Present in SoftwareRenderer2DState.cpp.
  5. deferred_source_lifetime_test.cpp (registered for SDL_GPU as SdlGpu_DeferredSourceLifetime) 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.