SpriteBatch: state, lifecycle and error semantics
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, its header and the ISpriteBatchRenderer contract at 009d40f5; no test was executed. The mock-renderer tests establish what reaches a renderer, not what each renderer draws.
SpriteBatch is implemented once, in the shared SpriteBatch.cpp, and lowered to one ISpriteBatchRenderer per renderer family. This page states the shared contract at this snapshot: the Begin overloads and their defaults, exactly when the requested states reach GraphicsDevice and what stays behind after End, how several batches on one device coordinate, what every misuse throws, the Draw overloads and the rotation origin, sub-pixel destinations, and texture lifetime. Sorting, flushing and per-renderer batching are on SpriteBatch sorting and batching; text is on SpriteFont text layout.
Begin overloads and defaults
SpriteBatch derives from GraphicsResource. SpriteBatch(GraphicsDevice&) asks the device's renderer for its ISpriteBatchRenderer; the CNAEXT default constructor creates a batch with no device and no renderer (every call still checks ordering, and draws do nothing); a CNAEXT constructor taking a std::unique_ptr<ISpriteBatchRenderer> exists so the neutral tests can record exactly what reaches the renderer. There are eight Begin overloads (SpriteBatch.hpp): Begin(), Begin(sortMode, blendState), and the five-, six- and seven-parameter forms, each of those three once with a BlendState by value and once with a nullable const BlendState*. All funnel into the seven-parameter pointer form, which resolves what the caller left out:
| Parameter | Omitted or nullptr |
|---|---|
| sort mode | SpriteSortMode::Deferred (only the zero-parameter Begin() omits it; every other overload takes it as its first argument) |
BlendState | BlendState::AlphaBlend (premultiplied alpha) |
SamplerState | SamplerState::LinearClamp |
DepthStencilState | DepthStencilState::None — never the state left by a preceding 3D draw |
RasterizerState | RasterizerState::CullCounterClockwise |
Effect* | the renderer's built-in sprite shader |
transform Matrix | identity; applied to every sprite before the sprite projection |
Every resolved value is applied on every Begin; nothing is inherited from a previous batch. The user-level tour of the same overloads is SpriteBatch: Begin() overloads and Tutorial 21: SpriteBatch deep dive.
When Begin's states reach the device
XNA applies a batch's render state at the moment the batch starts drawing, and CNA reproduces that timing:
Immediateapplies blend, sampler, depth-stencil and rasteriser state insideBegin, before the batch becomes active, because everyDrawsubmits at once.- Every other mode applies them in
End, immediately before the queue is flushed — even for an empty batch. BetweenBeginandEndthe device still reports whatever state it had (DeferredBeginAppliesRenderStatesOnlyWhenEndFlushes).
Applying means assigning through the public device properties — setBlendStateProperty, SamplerStates[0], setDepthStencilStateProperty, setRasterizerStateProperty — so the states go through the same validation and renderer path as a game's own assignments, and afterwards the renderer's private sprite sampler channel is refreshed from the batch's sampler and the device's sampler slots are pushed. A deferred batch keeps the caller's state objects until that boundary: the state types share their payload, so a property changed on the caller's BlendState, SamplerState, DepthStencilState or RasterizerState after Begin but before End is what gets applied, and once applied the objects are bound and reject further mutation (DeferredSamplerReadsEveryPropertyAtEndBoundary, DeferredEndReadsLateMutationsFromEveryStatePayload in SpriteBatchTests.cpp). Out-of-range sampler enum values reach the renderer as XNA's fallbacks (Linear, Wrap).
What End leaves on the device
End restores nothing. After a batch the device holds the batch's BlendState, its DepthStencilState (None by default, so depth testing is off), its RasterizerState and its sampler in SamplerStates[0] — the behaviour of XNA and FNA. A 3D draw that follows a sprite pass without assigning its own states inherits all four:
spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::Additive);
spriteBatch_->Draw(cornerTexture, Rectangle(0, 0, 4, 4), Color::White);
spriteBatch_->End();
// No state reassignment: this quad is drawn additively, with depth testing off and
// LinearClamp in sampler slot 0, because Begin/End changed the device and did not restore it.
basicEffect.getCurrentTechniqueProperty()->getPassesProperty()[0]->Apply();
device.DrawUserPrimitives(PrimitiveType::TriangleList, quadVertices, 0, 2);
// The usual fix: assign the 3D states explicitly before the 3D pass.
device.setBlendStateProperty(BlendState::Opaque);
device.setDepthStencilStateProperty(DepthStencilState::Default);
The EasyGL regression EasyGL_SpriteBatch_BlendStateLeak (easygl_spritebatch_blendstate_leak_test.cpp) checks exactly this on pixels: it clears to grey, draws a small additive sprite, then an opaque red quad with no state assignment, and reads a pixel away from the sprite; the green channel tells the inherited Additive state (red plus grey) apart from a hard-coded source-alpha blend (pure red). That test exists because an earlier EasyGL sprite renderer installed its own blend factors regardless of the requested state. The common layering pattern — 3D pass, then HUD sprites, then the next frame's 3D pass — is on Graphics state: common patterns.
Rasterizer state and scissoring through Begin
The RasterizerState argument is stored and applied like the others, so passing a state with ScissorTestEnable set to the five-parameter or longer Begin clips the batch to GraphicsDevice::ScissorRectangle without assigning the device's rasteriser state separately; CullMode and FillMode arrive the same way, and a later default Begin puts CullCounterClockwise (scissor off) back. The shared conformance scene spritebatch_begin_rasterizerstate_scissor_test.cpp draws a full-target sprite with the scissor state supplied only through Begin and checks one quadrant clipped and one drawn; it is registered for SDL_GPU, SOFTWARE, VULKAN, WEBGPU and the Direct3D 11/12 parity corpus. Older builds commented out the parameter and never read it, which is why porting notes written against them recommend assigning the rasteriser state on the device instead; that workaround is harmless but no longer needed. Whether a given renderer honours the scissor rectangle natively is its own contract.
DepthStencilState and layerDepth
Because Begin resolves a null depth-stencil argument to DepthStencilState::None, a sprite pass never inherits the depth test of the 3D pass before it. When a batch is given a depth-enabled state, layerDepth is a real depth value: the transformed sprite depth takes part in the depth test through the viewport's depth range. The neutral tests SpriteBatchLayerDepthParticipatesInDepthTesting, SpriteBatchTransformsLayerDepthBeforeDepthTesting, SpriteBatchUsesViewportDepthRange and SpriteBatchPreservesHomogeneousTransformW in BackBufferDepthStencilContractTests.cpp pin this on the renderers they run on (SOFTWARE, OPENGL33, OPENGL4, OPENGLES3, DIRECTX11, DIRECTX12 builds). An earlier implementation accepted the depth-stencil argument without applying it, so sprites inherited the previous 3D state.
Several batches on one device
Microsoft XNA coordinates every SpriteBatch attached to one device, and CNA keeps the same two counters on GraphicsDevice: how many batches are between Begin and End, and how many of those are Immediate. Any number of non-Immediate batches may be open at once — each queues its own sprites — but an Immediate batch is exclusive: Begin(Immediate) throws InvalidOperationException while another batch on the device is open, and any Begin throws while an Immediate batch is open. The check runs before any state is applied, and a rejected Begin is not counted. The counters are published only after the renderer accepted the batch, and released by a successful End (GraphicsDeviceCoordinatesImmediateAcrossBatchInstances). One consequence follows XNA deliberately: if a deferred End fails before reaching the renderer — for example because a state object passed to Begin was disposed in between — the pair stays open and still counts, even after the batch itself is disposed, so Immediate stays blocked on that device (FailedDeferredEndRetainsDeviceBatchAccountingEvenAfterDispose). Batches built with the default or the renderer-injecting constructor have no device and do not take part.
Misuse and failure semantics
Ordering errors use XNA's exception type:
| Misuse | Result |
|---|---|
Begin while the batch is already begun | System::InvalidOperationException “Begin has been called before calling End.” |
End without Begin | System::InvalidOperationException “End was called, but Begin has not yet been called.” |
any Draw or DrawString before Begin | System::InvalidOperationException (every one of the sixteen overloads is tested) |
| any call on a disposed batch | System::ObjectDisposedException (EveryRenderEntryRejectsDisposedBatch) |
an unnamed SpriteSortMode value | accepted by Begin; End throws NotSupportedException only if sprites are queued, before drawing any, and the pair then stays open |
So a porting engineer's XNA-faithful guard works as written:
try
{
spriteBatch_->Begin();
spriteBatch_->Begin(); // a forgotten End() on an early-return path
}
catch (const System::InvalidOperationException& ex)
{
// Reached: SpriteBatch reports ordering errors as XNA does.
CNA::Logger::Warn(std::string("sprite batch misuse: ") + ex.what());
spriteBatch_->End(); // the first Begin() is still active; close it
}
Earlier revisions threw a plain std::runtime_error for these two guards, which a typed InvalidOperationException handler never caught; OrderingErrorsUseXnaInvalidOperationException now pins the XNA type.
Failures inside the pair are handled so that the batch stays usable wherever that is safe. If the renderer rejects the setup in Begin — an effect or sampler it cannot honour — the custom effect is cleared on the renderer and the batch, the queue is emptied, the batch is left not-begun and the device counters untouched, so the caller can catch the capability exception and begin a valid batch at once. In End the rule depends on where the failure happened: a failure while applying deferred state or flushing the queue leaves the pair open, exactly as XNA does, so a repeated End retries the same work; a failure inside the renderer's own End cannot be retried safely, so the queue is discarded, the pair closed and the device counters released before rethrowing (EndExceptionDoesNotLeaveBatchActive). Dispose releases the renderer object and the queue and marks the batch disposed; it is idempotent.
Draw overloads and the rotation origin
Draw has ten overloads: the seven XNA forms and three CNAEXT conveniences. Every overload takes the texture as const Texture2D&; XNA's nullable Rectangle? is std::optional<Rectangle>, where std::nullopt means the whole texture.
| Overload | Destination and extras |
|---|---|
Draw(tex, Vector2 pos, color) | whole texture at its native size |
Draw(tex, pos, optional src, color) | the source region at its native size |
Draw(tex, pos, src, color, rotation, origin, float scale, effects, depth) | size = source size × uniform scale |
Draw(tex, pos, src, color, rotation, origin, Vector2 scale, effects, depth) | as above with a non-uniform scale — the two differ only in that parameter's type |
Draw(tex, Rectangle dest, color) | whole texture stretched into dest |
Draw(tex, dest, optional src, color) | source region stretched into dest |
Draw(tex, dest, src, color, rotation, origin, effects, depth) | full control; no scale parameter, because dest already implies it |
CNAEXT Draw(tex, float x, float y) | whole texture, white tint — the only form without a colour argument |
CNAEXT Draw(tex, dest, Rectangle src, color) and Draw(tex, dest, src, color, rotation, origin, effects, depth) | the destination-rectangle forms with a plain, non-optional source rectangle |
The origin is in texture space
origin is a point in source-texel space, not screen space: it is both the rotation pivot and the point that lands on position (or on the destination rectangle's top-left corner). Rotating a sprite around its own centre therefore means passing the source region's centre:
const Vector2 centre(texture.getWidthProperty() / 2.0f, texture.getHeightProperty() / 2.0f);
const float angle = static_cast<float>(gameTime.getTotalGameTimeProperty().getTotalSecondsProperty());
spriteBatch_->Draw(texture, spritePosition, std::nullopt, Color::White,
angle, centre, 1.0f, SpriteEffects::None, 0.0f); // spins in place
Passing Vector2::Zero instead — the default-constructed value, and so the easy mistake — pivots the sprite around its top-left corner, which makes it orbit spritePosition instead of spinning; the symptom looks like a rotation-math bug but is only the origin. ARotatedSpriteWithACentreOriginLandsWhereXnaPutsIt in SpriteBatchRasterizationTests.cpp pins the centre-origin placement, and SpriteBatchVectorDrawPreservesNegativeSourceOrigin checks that an origin outside the source region is honoured rather than clamped. DrawString has six overloads — std::string (UTF-8) or System::Text::StringBuilder, each in a four-argument form and nine-argument forms with a uniform float or Vector2 scale; its origin is measured in unscaled text-layout space (see text placement).
Sub-pixel destinations
The shared queue keeps a sprite's destination in floating point: SpriteInfo stores destX, destY, destWidth and destHeight as float, the Vector2-position overloads pass the position and the scaled size through unchanged, and the destination-rectangle overloads convert their integers exactly. XNA and FNA do the same — a sprite drawn at (10.5, 4.25) lands between pixels and its edges are filtered by the active sampler. Non-finite and very large values are carried through as well; the shared layer does not reject them (SpriteBatchNumericInputTest, SpriteBatchSubPixelDestinationTest).
Whether the fraction survives depends on the renderer seam. ISpriteBatchRenderer has a float-destination Draw overload whose default implementation truncates each component toward zero (non-finite values become 0, out-of-range values clamp to the int limits) and forwards to the integer-rectangle overload — the behaviour every renderer had before the float overload existed (IGraphicsRenderer.hpp). At this snapshot only the EasyGL family (the five GL identities), OPENGL4 and SOFTWARE override it; every other family receives whole-pixel destinations, so (10.9, 4.9) and (10.1, 4.1) both arrive as (10, 4). The renderer-independent test spritebatch_subpixel_contract_test.cpp compares a direct fractional draw with an integer draw shifted by the same amount through the transform matrix and requires identical bytes; it is registered for exactly those three families. Code that relies on smooth sub-pixel camera motion should either use one of them or apply the fractional offset through Begin's transform matrix, which every family except STUB overrides SetTransformMatrix to receive as a floating-point matrix. Text follows the same path: glyph destinations are no longer rounded (see text placement).
Texture validation and lifetime
Every draw validates the texture before anything is queued. A disposed Texture2D, or one whose renderer object is already gone, throws ObjectDisposedException — hardening beyond FNA, where a managed runtime fails safely and a C++ null dereference would not. A texture created by a different GraphicsDevice than the batch's throws InvalidOperationException at Draw, not at End: a refusal from the flush would leave the batch begun with the offending sprite still queued, so every later End would fail again (SpriteBatchCrossDeviceTest). A texture or a batch without a device is not treated as a mismatch.
A queued sprite holds a shared reference to the texture's renderer object, not to the Texture2D wrapper, and the queue is released only after the renderer's End — some renderers submit their last texture group only then. A deferred batch therefore stays correct when the wrapper it drew with is destroyed before End (ADeferredBatchOutlivesTheTextureWrapperItWasDrawnWith, and the same for every sorted mode); Immediate needs no retention because it submits inside Draw. Each accepted sprite also increments the Graphics/SpriteSubmissions frame counter (when diagnostics instrumentation is compiled in, CNA_DIAGNOSTICS_LEVEL 1 or higher) and, at submission, runs the device's draw-time profile checks for its texture (for example clamp addressing for a non-power-of-two texture under Reach).
Evidence and limits
Checked by reading SpriteBatch.cpp, SpriteBatch.hpp, ISpriteBatchRenderer in IGraphicsRenderer.hpp and the renderer overrides at 009d40f5; nothing was built or executed. Most of the contract is pinned by the mock-renderer tests in SpriteBatchTests.cpp, which establish what the shared layer sends to a renderer but not what any renderer draws; the pixel-level claims rest on the registered renderer tests named above, each for the families it is registered for. The three code examples were syntax-checked with g++ -std=c++23 -fsyntax-only -DCNA_RENDERER_VULKAN against the TARGET headers and a sibling sharp-runtime checkout (not pinned by TARGET), inside wrapper functions that declare the surrounding names.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- SpriteBatch · Graphics state
- Architecture
- Graphics architecture: state and render targets
- Tests and validation
- Test architecture