Indexed draw trace
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:
ThrowIfDisposed(); return silently if there is no renderer;renderer_->Ensure3DSupported("GraphicsDevice::DrawIndexedPrimitives"), which a renderer without 3D support uses to throw or, withWarnAndStub, to warn.numVerticesandprimitiveCountmust be positive;ValidateProfilePrimitiveCount()applies the profile's primitive limit;CheckedPrimitiveElementCount()derives the consumed index count from the topology.- No applied effect, no index buffer or no vertex buffer is an
InvalidOperationException; a bound but disposed buffer is anObjectDisposedException. 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) intoGpuDrawParams.- Offsets:
startIndexis copied unchanged;params.baseVertexisbaseVertexplusFoldedVertexStreamOffset(), added with a saturating helper so the fold itself cannot overflow. FillVertexStreamBindings()describes every bound stream (see below).- Range checks, only when there is no stream table (the legacy empty-declaration case) or the renderer answers true to
RequiresManagedBufferedDrawRangeValidationEXT(): non-negativestartIndexandminVertexIndex, the consumed indices inside the index buffer, and the declared vertex window inside each shader-consumed stream. ValidateVertexStreamCapability(): more than one stream of the same input rate needsMultiStreamVertexInputand at mostGetMaxVertexStreams()streams; the binding list is never silently truncated.applySamplerStatesToRenderer(): runsvalidateDrawState()— XNA's draw-time profile rules: no filtered sampling of point-filter-only float formats, clamp addressing for non-power-of-two textures underReach, no blending into non-blendable render-target formats — then pushes rasterizer state and every sampler slot's filter, address, anisotropy and mip state.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() | Families | Consequence |
|---|---|---|
| false (override) | EasyGL, SDL_GPU, Software | Ranges 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
ShaderEffectprogram goes toQueueCustomEffect3DDrawEXT, which captures every bound stream and the effect's own uniform block, and the method returns. - Shape selection.
CollectStockVertexStreamsEXTgathers the bound streams andSelectStockVertexShapeEXTchooses one stock shape from the declarations and the effect parameters: colored, textured, colored-textured, lit, alpha test, dual texture or environment map.SkinnedEffectandPbrEffectare selected by effect state (QueueSkinnedDraw,QueuePbrDraw, a PBR draw needing exactly one per-vertex stream); a record that matches no stock program throwsNotSupportedExceptionat draw time, behindRequireFaithfulDeclarationEXT, rather than failing later atPresent. - Snapshot. Each
Queue*Drawbuilds a typed command by value: the vertex streams (CaptureStockVertexStreamsEXT, binding the buffer's sharedSdlGpuBufferStorageEXTin 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 matrixApplyXnaPixelCenter(world * view * projection), the fog block, the sampled textures with theirkeepAlivereferences 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 appliesMultiSampleMaskall-or-none, because SDL_gpu reserves its sample mask: a mask keeping every sample draws, one keeping none drops the draw, and any other throwsNotSupportedException.
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:
- Acquire one
SDL_GPUCommandBuffer(owned by aFrameCommandBufferOwner) and insert the frame's queued debug labels. - 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. - 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).
- Walk the pass segments in bind order, open one native render pass per segment and call
RenderQueuedDrawsin the captured order; blit the readable backbuffer proxy to the swapchain if it is active. - 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).
- Reject
primitiveCount <= 0and any topology other than TriangleList, TriangleStrip, LineList, LineStrip andPointListEXT. - A compiled-effect draw requires a build with
CNA_SOFTWARE_COMPILED_EFFECTS(otherwiseNotSupportedException); compiled point lists are not implemented. - 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.
- 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. - 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).
- For each primitive, decode the index at
startIndex + local(16- or 32-bit), addbaseVertexonce, and resolve each consumed stream atstream.vertexOffset + vertexIndexwith that stream's own stride (a per-instance stream at its own offset). - Transform by
world * view * projection, clip to the frustum, fan the clipped polygon, and rasterise intoCurrentFramebuffer()— 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:
| Family | DrawIndexedPrimitivesEx | Where it is traced |
|---|---|---|
| EasyGL | EasyGLRenderer.cpp | EasyGL renderer internals |
| Headless | HeadlessRenderer.cpp: checks the effect inputs it was given and rasterises nothing | Headless renderer internals |
| Stub | no override; the base implementation forwards to DrawIndexedColoredPrimitives, which Stub implements as a no-op | Stub renderer internals |
| WebGPU, DirectX 9, FNA3D | WebGPURenderer.cpp, D3D9EffectDraw.cpp, Fna3dDraw.cpp | Not traced in the Development area yet. |
Where failures appear
| Symptom | Inspect first |
|---|---|
| Immediate public exception | Effect and buffer binding, disposed buffers, profile primitive limit, the range checks the renderer asked for, validateDrawState() profile rules, stream capability. |
| Wrong geometry on every family | GpuDrawParams construction, element-versus-byte arithmetic, FoldedVertexStreamOffset(), usage-index remapping, matrix extraction. |
| SDL_GPU throws at draw time | Shape 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 Present | EnsureFrameRendered: 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 draws | A field read at replay time instead of captured in the queued command or by PushDrawOrder. |
| Software differs from SDL_GPU | Software'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 hang | Swapchain 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.
| Level | Tests 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
GraphicsDevice.cpp:DrawIndexedPrimitives,FoldedVertexStreamOffset,FillVertexStreamBindings,ValidateVertexStreamRanges,ValidateVertexStreamCapability,applySamplerStatesToRendererandvalidateDrawState— the only shared validation and the exact delegate call.IGraphicsRenderer.hppin the renderer-contract headers (CNA/Internal/Renderers):GpuVertexStreamBinding,GpuDrawParams,DrawIndexedPrimitivesExandRequiresManagedBufferedDrawRangeValidationEXT— what can cross the boundary.SdlGpuRenderer.cpp:DrawIndexedPrimitivesEx,DispatchStockDrawEXT, oneQueue*Draw,PushDrawOrder,EnsureFrameRenderedandRenderQueuedDraws, in that causal order.SoftwareRenderer.cpp:DrawIndexedPrimitivesInternaland its immediate framebuffer writes; thenPresentinSoftwareRenderer2DState.cpp.deferred_source_lifetime_test.cpp(registered for SDL_GPU asSdlGpu_DeferredSourceLifetime) andsoftware_indexed_addressing_test.cpp: pick the fixture that establishes the semantics you change, not merely a test with “draw” in its name.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Capability answers, interface defaults and draw evidence — What a GraphicsCapability answer asks and guarantees in CNA: the 17 contract questions, renderer versus device polarity, known report mismatches, the unsupported-3D policy, four interface-default failure shapes and portable draw claims.
- Custom HLSL ShaderEffect on DIRECTX9 — How DIRECTX9 compiles a custom HLSL ShaderEffect, resolves uniform names through the constant table, feeds the SpriteBatch path, and why a custom effect does not reach its 3D draws.
- Effect object model: techniques, passes, parameters and the draw packet — What a CNA Effect contains in its stock, compiled and ShaderEffect forms: collections, pass application, Clone and Dispose, stock parameter tables versus XNA, EffectParameter storage and GpuDrawParams.
- Four shader routes: stock semantics, D3D9 stock sources, compiled effects and ShaderEffect — How CNA answers XNA's .fx: renderer-owned stock effects, DIRECTX9's recompiled Microsoft sources, compiled Effect Framework bytecode on qualified renderers, and the renderer-specific ShaderEffect contract.
- GraphicsDevice: the shared device contract — Exact device-level behaviour of CNA's GraphicsDevice: construction and windows, bound state objects, viewport and scissor, Clear and Present, bindings, draw calls, readback and extensions.
- SDL_GPU shader intake, pipeline keys and draw order — Why CNA's SDL_GPU renderer uses precompiled SPIR-V in SDL_gpu's set convention, which GLSL ShaderEffect accepts, how pipelines are keyed, what state is dynamic, and how draw order and vsync are kept.
- The renderer contract: IGraphicsRenderer defaults, factories and failure shapes — Which IGraphicsRenderer bodies a renderer family must write, what each inherited default does to a public call, how null factories fail, and the evidence ladder behind a feature.
- Vertex declarations, bindings and stream composition — From C++ vertex values to the renderer boundary: stream layouts, VertexDeclaration rules and profile limits, index widths, dynamic updates, VertexBufferBinding, semantic composition, the minimum-offset fold and draw validation order.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-006: SpriteEffect caches a MatrixTransform parameter that never exists, so OnApply() never sets the sprite projection — SpriteEffect looks up a MatrixTransform parameter its base Effect never creates, so Parameters["MatrixTransform"] is null and OnApply() returns before computing the orthographic projection and half-pixel offset.
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- 3D rendering: binding and draw calls · Renderers: modern GPU APIs · Renderers: CPU renderers
- Architecture
- Graphics architecture
- Maintainer workflow
- Fix a renderer bug · Add a regression test
- Tests and validation
- Test architecture · Verification: renderers
- Reference
- Test target index