Skip to content
The CNA BibleCNA 0.1.0-alpha.1 Edition

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.

SpriteBatch Begin resolves states. Draw calls append quad items. A flush occurs at End, on an immediate-mode draw, or when capacity or texture boundaries require it. Deferred modes optionally sort items, then group and submit them through the renderer SpriteBatch path. Submission does not guarantee one GPU draw call.
Figure 13.1: SpriteBatch’s CPU submission flow. “Batch” describes the public accumulation and flush contract; renderer implementations may issue more than one native draw.

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 64×64 sprite around its own center, rather than its top-left corner, means passing that texture’s own center as origin:

1 Vector2 center(texture.getWidthProperty() / 2.0f, texture.getHeightProperty() / 2.0f);
2 float angle = totalSeconds; // radians; grows every frame for a continuous spin
3
4 spriteBatch_->Draw(
5 texture, spritePosition, std::nullopt, Color::White,
6 angle, center, 1.0f, SpriteEffects::None, 0.0f);

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:

1 // D3D9-only half-pixel compensation, folded
2 // into the orthographic projection SpriteBatch’s own MatrixTransform
3 // applies -- not a separate step, not visible anywhere in shared code.
4 Matrix projection = Matrix::CreateOrthographicOffCenter(0, width, height, 0, 0, 1);
5 projection.M41 += -0.5f * projection.M11;
6 projection.M42 += -0.5f * projection.M22;
7 // MatrixTransform = SpriteBatch’s own transform, then this projection
8 // (row-vector convention: transform applied first).

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 1×1 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:

1 spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::Additive);
2 spriteBatch_->Draw(cornerTexture, Rectangle(0, 0, 4, 4), Color::White);
3 spriteBatch_->End();
4
5 // No BlendState reassignment here -- this quad draws with Additive, not AlphaBlend or Opaque,
6 // because SpriteBatch::Begin() changed GraphicsDevice.BlendState and End() never restores it.
7 device.DrawUserPrimitives(PrimitiveType::TriangleList, verts, 0, 2);

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 4×4 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:

1 const std::string text = "Game Over";
2 Vector2 size = font.MeasureString(text); // Vector2(width, height) in pixels
3 Vector2 centered(screenCenterX - size.X / 2.0f, screenCenterY - size.Y / 2.0f);
4
5 spriteBatch_->DrawString(font, text, centered, Color::White);

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:

1 void SpriteBatch::pushSprite(...)
2 {
3 // ...
4 if (sortMode_ == SpriteSortMode::Immediate)
5 flushSingle(info); // dispatched to the renderer right here, right now
6 else
7 spriteQueue_.push_back(info); // deferred -- but only the SORT, not the draw call
8 }
9
10 void SpriteBatch::flushBatch()
11 {
12 // ... optional std::stable_sort by layerDepth or texture pointer, per sort mode ...
13 for (const SpriteInfo& s : spriteQueue_)
14 flushSingle(s); // still one renderer->Draw() call per sprite
15 spriteQueue_.clear();
16 }

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:

1 const Vector3& cKern = spriteFont.kerning_[index];
2 if (firstInLine)
3 {
4 curOffset.X += std::abs(cKern.X); // first glyph: ABS of left bearing
5 firstInLine = false;
6 }
7 else
8 {
9 curOffset.X += spacing_ + cKern.X; // later glyphs: inter-char spacing + left bearing, no abs
10 }
11 curOffset.X += cKern.Y + cKern.Z; // every glyph: width + right bearing

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. MeasureString13.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 8×8 glyphs have zero-valued kerning (0, 8, 0) and spacing = 4.0f, making placement checkable in whole pixels:

1 // ’A’ (first in line): curOffset.X = abs(0) = 0 -> glyph drawn at dest x = 2 + 0 = 2
2 // after ’A’: curOffset.X += 8 + 0 = 8 (width + rightBearing)
3 // ’B’ (not first): curOffset.X += 4 + 0 = 12 -> glyph drawn at dest x = 2 + 12 = 14

Drawing "AB" at position = (2, 2) therefore places ’A’ at screen x[2,10) and ’B’ at x[14,22) — an exact 4-pixel gap at x[10,14) 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:

1 if (begun)
2 throw std::runtime_error("Begin has been called before calling End.");
3 // ...
4 if (!begun)
5 throw std::runtime_error("End was called, but Begin has not yet been called.");

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.

1 // Worked example: a porting engineer’s reasonable, XNA-faithful guard --
2 // this catch block never fires against SpriteBatch’s own Begin()/End()
3 // misuse, even though it would fire against the identical misuse on many
4 // other CNA classes that do throw the real InvalidOperationException.
5 try
6 {
7 spriteBatch_->Begin();
8 spriteBatch_->Begin(); // forgot the End() above -- a real, easy mistake
9 }
10 catch (const System::InvalidOperationException& ex)
11 {
12 // Never reached: SpriteBatch::Begin() throws std::runtime_error here,
13 // which this catch clause’s type does not match at all.
14 LogAndRecoverGracefully(ex);
15 }
16 // The exception propagates past this handler entirely, caught only by a
17 // broader catch (const std::exception&) further up the call stack, if one
18 // exists -- or terminates the program if it does not.

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.

Type at least three characters. Results are ranked by how often and where the words occur.