Chapter 13 SpriteBatch and 2D Rendering
SpriteBatch is among CNA’s most frequently used APIs. Every public renderer identity exposes the contract, but behavior and evidence still vary by implementation family. This chapter describes the current API, its supporting types (SpriteSortMode, SpriteEffects, SpriteFont), and selected defects that clarify the contract or the limits of its verification.
13.1 Begin, Draw, End
SpriteBatch inherits GraphicsResource and offers five Begin() overloads of increasing specificity. The public contract defaults whatever the caller omits: Begin() alone defaults to SpriteSortMode::Deferred, BlendState::AlphaBlend, a LinearClamp sampler, no depth-stencil state, RasterizerState::CullCounterClockwise, and an identity transform; the fuller overloads let a caller override the sort mode and blend state, the three optional state objects (nullptr falls back to the same defaults), a custom Effect (nullptr falls back to an internal default SpriteEffect), and finally an explicit transform Matrix applied to every sprite in the batch before projection.
The live CNA implementation does not currently honor that whole state contract. SpriteBatch::Begin() applies the requested/default blend and depth-stencil values and forwards the resolved sampler, but its RasterizerState* parameter is commented out and never read. FNA stores the caller’s value (or CullCounterClockwise, the matching RasterizerState preset) and applies it in PrepRenderState(). CNA instead leaves whatever rasterizer state was already on GraphicsDevice. The practical scissor consequence is direct: passing a rasterizer with ScissorTestEnable=true to the normal full Begin() overload cannot enable clipping on any renderer; a previously-enabled state can also leak through a later default Begin(). A game can work around the first half only by assigning GraphicsDevice.RasterizerState separately before drawing. Section 19.4.3 then determines whether the selected renderer can actually honor it.
Draw itself has nine overloads: six are the canonical XNA forms — position-or-destination-rectangle, an optional source rectangle (represented as std::optional<Rectangle> rather than C#’s nullable Rectangle?), color, and progressively more control over rotation, origin, scale (as either a uniform float or a non-uniform Vector2), SpriteEffects, and layer depth — and three are CNAEXT raw texture-and-rectangle convenience forms layered on top. DrawString adds six more overloads (string or StringBuilder, each in a simple 4-argument form, a uniform-scale 9-argument form, and a non-uniform-scale (Vector2 scale) 9-argument form).
13.2 Draw overloads and rotation origin
The nine Draw overloads split cleanly into two families by their second parameter — a Vector2 position (four overloads, increasingly parameterized) or a Rectangle destinationRectangle that scales the texture to fit (three overloads) — plus the two CNAEXT raw float x, float y convenience forms already noted. Every XNA-compatible overload takes an explicit Color tint — there is no overload that omits it, unlike the CNAEXT convenience forms. The table below abbreviates each overload’s parameter names for width (tex for texture, pos for position, and so on) — the full, exact parameter names for every overload are shown in the parameterized example below the table.
| Family member | Adds, relative to the simplest form in its family |
|---|---|
| Draw(tex, pos, color) | The simplest XNA form: whole texture, no rotation, unit scale. |
| Draw(tex, pos, srcRect, color) | An optional source sub-rectangle (std::optional<Rectangle>, not C#’s nullable Rectangle?) — the sprite-sheet-cell pattern. |
| Draw(tex, pos, srcRect, color, rot, origin, scale, fx, depth) | Uniform float scale. |
| Draw(tex, pos, srcRect, color, rot, origin, scale, fx, depth) | Same, but scale is a non-uniform Vector2 — these two fully-parameterized overloads differ only in that one field’s type. |
| Draw(tex, destRect, color) | Whole texture, scaled to fill destRect. |
| Draw(tex, destRect, srcRect, color) | Adds the source sub-rectangle. |
| Draw(tex, destRect, srcRect, color, rot, origin, fx, depth) | Full control, destination-rectangle family — note there is no separate scale parameter here at all, since the destination rectangle already implies it. |
The parameterized overloads share origin, a point in texture space (not screen space) that both the rotation pivot and the drawn position are measured relative to. Rotating a sprite around its own center, rather than its top-left corner, means passing that texture’s own center as origin:
Passing Vector2::Zero for origin instead — the easy mistake, since it is also the type’s default-constructed value — rotates the sprite around its top-left corner, which reads as the sprite “orbiting” spritePosition in a circle rather than spinning in place; the visual symptom is easy to misdiagnose as a rotation-math bug when the actual cause is simply the wrong origin.
13.3 Vector positions are quantized before they reach a renderer
The Vector2-position overloads do not preserve sub-pixel destinations. Their shared queue record stores an integer Rectangle; CNA validates the position and scaled size, then converts each floating component with truncation toward zero. Thus (10.9f, 4.9f) and (10.1f, 4.1f) both enter every renderer as (10,4). This is a shared SpriteBatch fidelity difference from XNA, not a renderer-specific rasterization rule.
Text follows a subtly different policy. DrawString() computes each transformed glyph destination in floating point and rounds it to the nearest integer before storing the same rectangle shape. A sprite and a glyph submitted at the same fractional coordinate can therefore land on adjacent pixels. Both conversion helpers reject non-finite values and results outside the signed 32-bit range; the safety check prevents undefined conversion, but does not restore the lost fractional position. Code that depends on smooth sub-pixel camera motion must account for this quantization above the renderer layer.
13.4 The D3D9 half-pixel offset and a mutation-testing trap
XNA 4.0 ran exclusively on Direct3D 9, whose rasterizer places texel centers at integer pixel coordinates rather than pixel centers — the well-known “D3D9 half-pixel offset” problem, notorious enough that porting guides for other 2D APIs still warn about it decades later. SpriteBatch’s own shared, renderer-agnostic SpriteBatch.cpp carries no trace of this compensation at all, which is exactly correct behavior for every renderer built on a modern API (EasyGL, Vulkan, BGFX, D3D11, D3D12, WebGPU): none of them share Direct3D 9’s own texel-center convention, so there is nothing to compensate for, and adding an offset unconditionally would introduce an error on every renderer that does not need one. On D3D9, the compensation lives one level down, inside D3D9SpriteBatchRenderer::BuildMatrixTransformEXT specifically — baked directly into the projection matrix SpriteBatch constructs, the same technique the classic XNA 4.0 D3D9 fix itself used:
Verification against the XNA 4.0 oracle (Chapter 28) exposed a mutation-testing trap: two independently designed tests could not detect whether the offset was present. A test texture cannot detect it, because the half-pixel offset shifts which texel a given screen pixel samples, not where a sprite’s geometric edges land on screen — a texture with only one texel has nothing else to shift onto, so removing the offset entirely changes nothing a one-texel-texture test can observe. Separately, sampling only at a drawn rectangle’s own quadrant-center points misses it too, for the same underlying reason: those points are exactly where a coarse boundary check happens to line up regardless of the sub-pixel shift. Both traps were only caught by deliberately mutation-testing the passing test itself — commenting out the two M41 / M42 lines above and re-running the existing check suite, which still passed with the fix removed. The replacement samples a specific internal texel boundary a rotated/flipped sprite’s own blended edge produces — a pixel confirmed to shift from one exact, named RGB value to another under the same mutation, the first check in the suite that changed under the mutation. A regression test must fail when its protected behavior is removed; mutation testing establishes that property directly.
13.5 SpriteSortMode and SpriteEffects
SpriteSortMode has five values: Deferred, Immediate, Texture, BackToFront, and FrontToBack. Only the latter two honor layerDepth; the other modes ignore it by design.
Texture mode applies a stable sort to raw texture pointers. The TextureGroupsDrawsByTextureAndPreservesGroupOrder test establishes two properties: sprites sharing a texture become adjacent, and their relative submission order is preserved. The order between different texture groups is unspecified because it depends on pointer order. Overlapping sprites must therefore not use Texture mode when the A-versus-B group order matters. A separate SDL Renderer pixel test checks that the reordered draw calls bind the intended textures; a mock renderer can establish grouping but cannot observe that renderer-side binding.
Limitation. SpriteEffects is a plain enum class with values 0–2 and no bitwise operators. Microsoft XNA defines it as a flags enum, where horizontal and vertical flips can be combined as value 3. CNA’s DrawString direction tables also contain only three entries. Combined-axis text flipping is therefore not expressible through the current API. The pinned docs/spritefont-support.md records this as an open API-completeness gap.
13.6 Historical SpriteEffects layout defect
Historical note. An earlier DrawString path flipped each glyph’s texture coordinates without mirroring the glyph sequence or positions. The correction added FNA-shaped axis-direction and axis-mirroring terms in the shared SpriteBatch.cpp. Because the defect was above the renderer interface, one shared fix corrected every renderer path.
13.7 Begin() leaves GraphicsDevice.BlendState active
The BlendState passed to Begin()—or BlendState::AlphaBlend when omitted—remains assigned to GraphicsDevice.BlendState after End(). This matches FNA. A following 3D draw inherits that mode unless the game assigns another blend state:
Historical note. EasyGL once ignored the requested state and installed hard-coded source-alpha factors. A pixel regression now clears to gray, draws a small additive sprite, then draws an opaque 3D quad without another state assignment. Its off-sprite green-channel sample distinguishes the requested-state behavior from the former hard-coded blend.
Limitation. On EasyGL, a full-backbuffer SpriteBatch draw before the frame’s first 3D draw can still break that frame’s 3D rendering. Window-title readback, viewport-reset order, and render-target binding were excluded as causes, but the defect was not root-caused at the pin. The blend-state regression uses a corner sprite and therefore does not cover this trigger.
13.8 SpriteFont
SpriteFont is not a GraphicsResource. It owns a texture atlas, glyph bounds, cropping data, a character set, line spacing, character spacing, kerning data, and an optional default character. Its constructor is CNAEXT and public. Microsoft XNA’s SpriteFont constructor is internal, reachable only from the compiled .xnb content pipeline’s SpriteFontReader; CNA lacks that reader and exposes construction instead.
MeasureString exists for both string and StringBuilder. The latter forwards to the string implementation, a simplification available in C++ because there is no garbage-collection-pressure argument against sharing code here. A trailing "\n" adds a second empty line to the measured height: Y = 2 LineSpacing. Both the newline handler and the loop’s final height addition run, matching Microsoft XNA’s result.
For text encoding, CNA’s String is UTF-8 (std::string), and SpriteFont / SpriteBatch decode each glyph via an internal DecodeUtf8CodePoint helper. Because charcs (the glyph-key type, per Chapter 3’s type-alias table) is 16 bits wide, only Basic Multilingual Plane code points can serve as glyph keys. An invalid or truncated UTF-8 sequence decodes to ’?’; the byte index still advances by at least one.
This example uses MeasureString to center HUD text before calling the simplest DrawString overload:
13.9 SpriteFont evidence by renderer
docs/spritefont-support.md verifies the property surface, MeasureString, single- and multi-glyph placement, newline handling, default-character fallback, single-axis flip, and rotation/scale as pixel-correct on both SDL_Renderer and EasyGL. Vulkan and BGFX reuse the shared layout logic, but that source relationship is not a renderer-engaged or pixel oracle. Their SpriteFont paths have not been independently re-verified, as also recorded in docs/graphics-renderer-feature-matrix.md. Combined-axis flip is not representable on any renderer at all, for the SpriteEffects reason above.
13.10 Two corrected SDL_RENDERER defects
Historical note. The recorded SDL Renderer campaign found two shared-API defects. First, SDL_RenderTextureRotated’s pivot convention differed from XNA’s rotation-origin convention; the correction offsets the destination rectangle. Second, the renderer ignored Begin()’s transformMatrix. The correction added a path using SDL_RenderTextureAffine, used only when the transform actually differs from identity, so the common (and cheaper) untransformed path is unaffected.
13.11 Flush timing: sorting without draw-call coalescing
The class is named SpriteBatch, and SpriteSortMode::Deferred — the default — sounds like it defers submission to build one large GPU-side batch. Reading SpriteBatch.cpp’s own pushSprite / flushSingle / flushBatch shows a narrower design with performance implications for code accustomed to GPU-side draw-call coalescing:
Immediate sends every Draw() directly to flushSingle; it is the only mode in which renderer state changes between sprite submissions retain their exact position. The other modes queue and optionally sort, but flushBatch() still calls the renderer once per sprite. CNA’s batch therefore coalesces sort decisions, not GPU draw calls: roughly one thousand sprites produce roughly one thousand renderer submissions.
pushSprite also rejects a disposed texture with ObjectDisposedException. This is CNA-specific hardening. Without the guard, a disposed Texture2D would leave a null renderer pointer for GetRenderer() to dereference.
13.12 DepthStencilState at Begin
Current contract. Begin() assigns the resolved DepthStencilState; a null argument becomes DepthStencilState::None. It does not inherit the state left by a preceding 3D draw. By contrast, End() does not restore the earlier BlendState, as described in §13.7.
Historical note. An earlier implementation accepted depthStencilState without assigning it to the device, so a sprite pass inherited prior 3D state. Applying the resolved state in Begin() closed that defect and aligned the null case with FNA.
13.13 Per-glyph advance math in DrawString
§13.8 above already named the raw ingredients — kerning_, a Vector3 per glyph packing (leftBearing, width, rightBearing) — but MeasureString and DrawString are two independent implementations of the identical walk, in two different files (SpriteFont.cpp and SpriteBatch.cpp respectively), and it is worth confirming directly, rather than assuming, that they agree. Reading SpriteBatch::DrawString shows the same three-line shape, glyph by glyph:
Only the first glyph applies std::abs() to its left bearing. This prevents a negative initial bearing from moving the whole string before position; later negative bearings can still tuck a glyph toward its predecessor. MeasureString (§13.8) applies the same rule, so measured and drawn placement agree.
The focused EasyGL fixture is easygl_spritefont_multiglyph_spacing_test.cpp. It exercises the non-first-glyph branch. Its two glyphs have zero-valued kerning (0, 8, 0) and spacing = 4.0f, making placement checkable in whole pixels:
Drawing "AB" at position = (2, 2) therefore places ’A’ at screen and ’B’ at — an exact 4-pixel gap at that must stay background-colored for the test to pass. A sample inside the gap establishes that spacing was applied, while each glyph’s distinct fill color (white for ’A’, green for ’B’) at its expected position rules out an index mix-up between the two glyphs — a bug shape that a single-glyph test, no matter how many pixels it samples, structurally cannot detect at all, since there would be no second glyph to place at the wrong index.
13.14 Exception-type mismatch for nested Begin()
Calling Begin() a second time without an intervening End() — or calling End() before any Begin() at all — is a real, commonly-hit misuse case (a forgotten End() inside an early-return branch, most often), and CNA does throw for it. Reading the real implementation shows exactly what it throws, though, and it is not the type a reader would reasonably expect from real XNA:
Real XNA’s own SpriteBatch.Begin() throws InvalidOperationException for the identical misuse, and System’s own InvalidOperationException type exists in this codebase, real and used elsewhere (Chapter 16 and others cite it directly) — but SpriteBatch itself throws a plain std::runtime_error here instead, confirmed by reading both guard sites directly rather than assumed from the message text alone. The two exception hierarchies do not meet until std::exception itself: InvalidOperationException derives from SystemException, which derives from System::Exception — a hierarchy entirely separate from std::runtime_error’s own std::exception lineage, confirmed by reading InvalidOperationException.hpp / SystemException.hpp directly.
This is a narrower, more precisely scoped gap than “wrong error message” or “doesn’t throw at all”: the throw itself is correct and reliable (a real, hittable misuse genuinely does terminate the batch and report a real diagnostic message), and any code written against a generic catch (const std::exception&) handler — the common, safe default in ordinary C++ — catches it exactly as expected, message text and all. The gap only bites a porting engineer who specifically wrote a typed InvalidOperationException catch clause around SpriteBatch usage, reasonably expecting the same exception hierarchy this book’s other chapters confirm many other CNA classes do use faithfully for the identical real-XNA exception type.
13.15 Where this leaves a porting engineer
SpriteBatch has broad cross-renderer coverage because each renderer must expose it. The evidence is not uniform: shared queue logic, renderer engagement, targeted pixel tests, and XNA-oracle comparisons establish different claims. It is a practical portability baseline for 2D ports, but the implementation-family matrix and open limitations above still apply.