SpriteBatch sorting, flushing and renderer batching

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. Checked by reading SpriteBatch.cpp and the renderer sprite paths at 009d40f5; no test was executed. The Direct3D 9 oracle match is CNA's own recorded result under Wine and DXVK, not repeated here.

“Batch” names two different things in CNA: the shared SpriteBatch queues and sorts sprites, and each renderer family decides how many native draws it spends on them. This page follows a sprite from Draw to the renderer — the flush boundaries, the five sort modes and the XNA quicksort CNA reproduces on purpose, what each kind of renderer does with the stream, the viewport-local sprite projection, the Direct3D 9 half-pixel offset and the mutation-testing lesson it taught — and closes with the per-renderer evidence. The state and error contract is on SpriteBatch semantics.

From Draw to the renderer

 Begin()  resolve states ---- Immediate: apply states now, renderer.SetImmediateMode(true)
    |
 Draw(...) / DrawString(...)   validate texture (disposed? other device?)
    |
    +-- Immediate --> flushSingle ------------------------------+
    |                                                           |
    +-- other modes --> CPU queue (float destination, texture   |
                        renderer reference, colour, depth ...)  |
                                                                |
 End()  apply states --> sort by mode (XNA quicksort) --> flushSingle per sprite
                                                                |
                                                                v
                         ISpriteBatchRenderer::Draw  (one call per sprite)
                                                                |
                         family: accumulate quads; flush on texture change,
                         capacity, custom-effect change or End()
                                                                |
                                                                v
                         native draws (GPU) | per-sprite calls (SDL_RENDERER,
                         DOM) | CPU raster (SOFTWARE) | trace (HEADLESS)
Figure. The sprite submission path. Begin resolves the states and, for Immediate only, applies them at once. Each Draw validates its texture and then either submits immediately (Immediate) or appends a record with a floating-point destination to a CPU queue. For the other modes, End applies the states, sorts the queue by the mode's key and hands the renderer one Draw call per sprite. Grouping into native draws happens below that seam and differs by family, so one ISpriteBatchRenderer::Draw is not one GPU draw and a sort is not a coalescing guarantee.

The flush boundaries are therefore: every Draw in Immediate mode, and End for the other four modes. flushBatch() sorts if the mode asks for it and then calls flushSingle() for every queued sprite; flushSingle() runs the device's draw-time checks for the sprite's texture and calls the renderer's float-destination Draw once. Roughly a thousand sprites produce roughly a thousand renderer calls; the shared class coalesces sort decisions, and any coalescing into fewer native draws is the family's work (below). The source is SpriteBatch.cpp (pushSprite, flushSingle, flushBatch, End).

The five sort modes

SpriteSortMode has five values with XNA's ordinals: Deferred (0), Immediate (1), Texture (2), BackToFront (3) and FrontToBack (4). Its underlying type is fixed as int because XNA callers can cast any integer into the enum; Begin stores an unnamed value, and End throws NotSupportedException for it only when there are sprites to sort (SpriteSortMode.hpp). Only BackToFront (descending layerDepth, far first) and FrontToBack (ascending) read layerDepth for ordering; Deferred keeps submission order, Texture groups by texture and Immediate submits in call order. The user guide with the three oracle frames is Tutorial 25: SpriteSortMode and layering.

XNA's unstable quicksort, reproduced

Microsoft XNA sorts its sprite index array with .NET Framework 4's Array.Sort, a quicksort that is deliberately not stable. CNA reproduces that algorithm rather than calling std::sort or std::stable_sort (XnaArraySort: median-of-three by pairwise swaps of the first, middle and last element, then the partition loop, recursing into the smaller side). Two reasons are given in the source: the visible order of equal keys has to match XNA, and XNA's depth comparer treats NaN as equal to every value, which would be undefined behaviour for a standard C++ sort but is well defined in the reproduced loop. The consequences are observable:

  • Equal depths are reordered. Three sprites submitted with the same layerDepth come out in the order 3, 2, 1 in both depth modes (EqualDepthsMatchXnaArraySortOrdering); two overlapping sprites with equal keys come out reversed, so the first-issued one ends on top. Give overlapping sprites distinct depths, or draw them in separate Begin/End pairs.
  • NaN depths compare equal to everything and land wherever the partition leaves them (NonFiniteLayerDepthsMatchXnaArraySort).
  • Texture mode sorts on the renderer object's address — the ITextureRenderer shared by all copies of a texture, not the Texture2D wrapper — in descending address order. Sprites that share a texture become adjacent, but the order between groups depends on allocation addresses and can differ between runs and renderers, and within a group the quicksort reorders equal keys as above (TextureGroupsDrawsAndMatchesXnaEqualKeyOrdering records the reversal of the two outer entries of an A, B, A submission). As Microsoft's own documentation scopes it, Texture mode is for sprites that do not overlap.

Descriptions of CNA's sorting as a stable sort of texture pointers are older than this reproduction. The shared conformance scene spritebatch_sort_mode_semantics_test.cpp (registered for EasyGL, OPENGL4 and VULKAN) asserts what the modes guarantee on pixels: no sprite is lost, each reaches its own destination, and equal-key sprites come out reversed.

What the renderers do with the stream

Below the ISpriteBatchRenderer seam the families differ in kind:

KindFamilies (read at this snapshot)What a sprite costs
GPU quad batchersEasyGL (FlushBatch, at most 2,048 sprites per batch), OPENGL4 (the same limit), DIRECTX11 and DIRECTX12 (FlushBatch, 65,536 vertices per batch), DIRECTX9, FNA3D (Flush on a texture change, at most 16,384 quads per native draw), VULKAN (FlushTexture on a texture change), WEBGPU and SDL_GPU (sprites queued in the renderer)Consecutive sprites with the same texture share one native draw; a texture change, the capacity limit, a custom-effect change or End closes the run. A compiled XNA effect applies its passes once per run.
Per-sprite 2D APIsSDL_RENDERER (one SDL_RenderTextureRotated or SDL_RenderTextureAffine call per sprite), the browser DOM renderers (one element per sprite), CANVAS (one drawImage per sprite)One API call per sprite, regardless of order.
CPU rasterisersSOFTWARE, GDI, PORTABLEGLRasterised into the framebuffer in submission order.
No pixelsHEADLESS (validates and traces), STUBNothing drawn.

So on a GPU family the practical draw-call count is the number of texture runs after sorting, which is why atlases and Texture mode help there — and why the same advice changes nothing on SDL_RENDERER. The 2,048-sprite chunk is XNA's and FNA's own native batch size; a batch that grew past it would wrap a 16-bit quad index. The conformance scene spritebatch_large_batch_contract_test.cpp makes the 16,385th same-texture sprite the only visible output and is registered for EasyGL, OPENGL4 and SOFTWARE.

Immediate mode below the seam

Immediate makes the shared layer call the renderer inside each Draw, and Begin tells the renderer so through SetImmediateMode (overridden by EasyGL, OPENGL4, SOFTWARE, VULKAN, SDL_GPU, FNA3D, CANVAS and the two DOM renderers). What a renderer does with that differs. The conformance scene spritebatch_immediate_submission_contract_test.cpp switches render targets between two Draw calls and requires each sprite to stay in the target that was active when it was drawn; it is registered for EasyGL, OPENGL4 and SOFTWARE. A texture whose contents change between two Draw calls is a different matter, and it depends on the family. EasyGL, OPENGL4 and FNA3D flush the sprite before Draw returns while SetImmediateMode(true) is in force (EasyGL and OpenGL 4 end their sprite Draw with if (immediateMode_) FlushBatch(); EasyGL was changed to do so on 2026-09-09, SOFTWARE-252), so a sprite that was already issued keeps the contents the texture had when it was drawn. VULKAN records its sprites for replay at present, and SDL_GPU, the Direct3D families and WEBGPU either do not override SetImmediateMode or read it only for compiled effects, so they keep accumulating one run per texture until a texture change, the capacity limit or End and rasterise the batch against the texture's final contents. CNA records the Vulkan divergence as a deliberate refusal rather than an open defect (VULKAN-057, “Partial (divergent by decision)” in its XNA coverage document: forcing a synchronous submission per sprite was measured at 2.7–3.4× the cost on llvmpipe and 11.6× on RADV, and on the back buffer it would undo the one-acquire-one-submit-one-present frame contract); CNA's notes that call the divergence CNA-wide predate the EasyGL change. Do not rely on Immediate to snapshot a texture that is rewritten mid-batch on every family; end the batch instead.

The sprite projection is viewport-local

XNA builds the sprite projection from the active viewport: CreateOrthographicOffCenter(0, Viewport.Width, Viewport.Height, 0, 0, 1), applied after Begin's transform matrix. Sprite coordinates are therefore relative to the viewport's top-left corner, and the viewport's X/Y position the result through the rasteriser viewport — they are never subtracted from sprite coordinates. Each renderer family builds this projection itself; the conformance scene spritebatch_custom_viewport_test.cpp draws a viewport-local rectangle inside a custom viewport and checks its exact footprint, including the converse case (a full-target batch followed by a sub-viewport must stay full-target). It is registered for EasyGL, OPENGL4, VULKAN, WEBGPU and the Direct3D 11/12 parity corpus; companion scenes cover render targets (spritebatch_custom_viewport_rt_test.cpp) and a viewport change between batches (spritebatch_viewport_switch_test.cpp). Before those fixes several GPU families built the projection over the full target, which either squeezed sprites into the viewport or ignored it. On EasyGL the default viewport under Letterbox is the presentation rectangle rather than the whole drawable, and the sprite path compares against that rectangle so letterbox bars are not mistaken for a game-set sub-viewport. The viewport rules themselves are on Viewport and scissor rectangle.

The Direct3D 9 half-pixel offset

XNA 4.0 ran on Direct3D 9, whose rasteriser puts texel centres at integer pixel coordinates rather than at pixel centres. XNA's sprite batch compensated inside the projection it built, and FNA — a reimplementation targeting modern APIs — has no such term. CNA's shared SpriteBatch.cpp carries no compensation either, which is correct for every renderer built on an API with pixel-centre conventions; adding an offset in the shared layer would misplace sprites everywhere else. The compensation lives in exactly one place, D3D9SpriteBatchRenderer::BuildMatrixTransformEXT in D3D9SpriteBatch.cpp, folded into the sprite projection:

// DIRECTX9 only: the half-texel shift is part of the sprite MatrixTransform.
Matrix projection = Matrix::CreateOrthographicOffCenter(
    0.0f, viewportWidth, viewportHeight, 0.0f, 0.0f, -1.0f);   // far plane -1: identity Z row
projection.M41 += -0.5f * projection.M11;                       // half a pixel in NDC, X
projection.M42 += -0.5f * projection.M22;                       // half a pixel in NDC, Y
// MatrixTransform = Begin's transform matrix, then this projection (row vectors).

The far plane is −1, not the 1 that XNA's documented formula suggests: with 1 the projection maps any layerDepth above 0 to a negative Direct3D 9 clip-space depth, and the sprite is clipped away silently. That defect surfaced only when the first oracle scene drew with a non-zero layerDepth; with −1 the Z row is the identity and the depth range is the documented [0, 1]. The M41/M42 shift depends only on M11/M22, so the far-plane fix did not move any earlier result. CNA's comment records the formula as verified against the real XNA 4.0 runtime through the oracle scenes, not derived from first principles.

A test that could not fail

The half-pixel offset is also a lesson in regression tests. The first checks in directx9_spritebatch_test.cpp passed whether or not the offset was present: a 1 × 1 texture stretched into a rectangle, and samples at the centres of solid-colour quadrants. The offset shifts which texel a screen pixel samples, not where a sprite's geometric edges fall, so a one-texel texture has nothing to shift onto and a quadrant centre is far from any texel boundary. Deliberately commenting out the two M41/M42 lines — mutation testing the test — left every check green. The replacement samples the internal boundary between two texels of a flipped sprite, at the destination's horizontal centre under the default LinearClamp filter: with the offset that pixel is (130, 125, 0), without it (128, 128, 0), and the expected value is taken from the XNA oracle scene rather than reasoned out. The general rule: a regression test must fail when the behaviour it protects is removed, and mutating the protected code is the direct way to establish that. The test is registered as DirectX9_SpriteBatch; like every Direct3D 9 check it runs where a Direct3D 9 device exists — in CNA's setup, cross-compiled and run under Wine with DXVK on Linux.

SDL_RENDERER specifics

SDL_RENDERER draws each sprite with one SDL call, and two conventions had to be bridged (SdlRenderer.cpp). XNA's origin maps to the destination position and stays there under rotation, while SDL_RenderTextureRotated places the rectangle unrotated and pivots around a point inside it; the renderer offsets the destination by the scaled origin so the pivot lands where XNA puts it. SDL_RenderTextureRotated also cannot take an arbitrary transform, so when Begin's transform matrix is not the identity the renderer computes the rotated corners, transforms them with the matrix and draws through SDL_RenderTextureAffine, expressing flips by permuting which corner is the origin; the common untransformed path keeps the cheaper rotated call. Both were defects in earlier builds (the pivot was misplaced and the transform matrix ignored). The renderer takes whole-pixel destinations (it has no sub-pixel override), and SDL_RENDERER has its own SpriteFont pixel tests (see text evidence).

Evidence by renderer

The claims on this page rest on three kinds of evidence, which establish different things:

  • Shared-layer tests with a recording renderer (SpriteBatchSortModeTest, SpriteBatchRendererInjectionTest and neighbours in SpriteBatchTests.cpp): the exact order and parameters that reach a renderer, independent of any family. They say nothing about pixels.
  • Shared conformance scenes in modules/graphics/examples, each registered per family: the scissor-through-Begin, custom-viewport, sub-pixel, large-batch, immediate-submission, sort-mode and sprite-then-3D scenes named on these pages, plus spritebatch_sampler0_publication_test.cpp (the batch's sampler left in SamplerStates[0]; EasyGL, OPENGL4, SDL_GPU, VULKAN) and spritebatch_3d_order_test.cpp (sprite and 3D draws interleaved; registered for most GPU families, SOFTWARE and HEADLESS). Each is evidence only for the families that register it; the lists above are from the CMake registrations at this snapshot. None was executed for this page.
  • A per-family pixel test for Texture mode: SDL_Renderer_SpriteBatch_TextureSort (sdlrenderer_spritebatch_texture_sort_test.cpp) submits four non-overlapping sprites of two textures in a scrambled A/B/A/B order and reads pixels back: each destination must show its own texture's colour whichever group the pointer sort puts first. It supplies what a recording renderer cannot, proof that the renderer still binds the right texture for each reordered call, and deliberately does not assert which group comes first. Its header comment still describes std::stable_sort; the code is XnaArraySort. No other family registers a Texture-mode pixel scene.
  • XNA oracle scenes: sprite_basic_quad, sprite_rotated_quad, sprite_flipped_quad, sprite_wrap_quad, sprite_mirror_quad, sprite_multitexture_quad and the three sprite_sortmode_* scenes in tools/xna-oracle/scenes, captured from the real XNA 4.0 runtime under Wine and DXVK. CNA's records report the DIRECTX9 renderer matching them at tolerance 0; that comparison is not run in CI and was not repeated here. They cover placement, rotation, flipping, addressing, a texture change mid-batch and the depth modes; they do not cover Immediate or Texture ordering.

One EasyGL report remains unresolved in CNA's own records: a full-back-buffer SpriteBatch draw before the frame's first 3D draw was reported to break that frame's 3D rendering, with the cause not isolated, and CNA's migration guide still lists it. A later renderer-neutral scene, fullscreen_spritebatch_then_3d_test.cpp, registered as EasyGL_FullscreenSpriteThen3D and Vulkan_FullscreenSpriteThen3D, did not reproduce it on either renderer according to CNA's Vulkan plan, which records that as weaker than “fixed”. Its status is tracked through the known-issues process, not here.

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

Maintainer workflow
Add a regression test
Tests and validation
Test architecture