SDL_RENDERER: the 2D contract, its refusals and its evidence

CNA snapshot 009d40f5  ·  Deep Dives › Renderers  ·  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. Read from SdlRenderer.cpp, the shared GraphicsDevice and resource code, the family's test sources and CMake registrations at 009d40f5; no test was executed, and the Wrap pixel outcome is the finding recorded in the registered clamp test's source, not an observation made here.

SDL_RENDERER is CNA's narrowest renderer by design and one of its most exhaustively tested: a 2D path built directly on SDL3's own texture-blit renderer, with no 3D pipeline, no programmable shader stage, no depth or stencil buffer and no multisampling. It is also the default renderer everywhere except Linux and the web, and the only renderer allowed on iOS, so its exact boundary matters to more games than any other 2D family. This page records where that boundary sits, which XNA features are native, emulated, refused or not honoured, which earlier defects shaped the current code, and how its evidence is produced.

Scope by design

The family is one source file, SdlRenderer.cpp, and one header, SdlRenderer.hpp. The descriptor asks for a plain window and the video subsystem, and the factory resolves the SDL3 window behind the platform's surface handle, so the renderer requires the SDL3 platform (the SDL2 platform and CNA_ENABLE_SDL=OFF refuse it by name). SDL_CreateRenderer(window, nullptr) lets SDL pick its own backend, which the constructor logs. Every CNA texture is an SDL_PIXELFORMAT_RGBA32 SDL texture drawn with SDL_RenderTexture, SDL_RenderTextureRotated or SDL_RenderTextureAffine; a render target is an SDL_TEXTUREACCESS_TARGET texture. At start-up the renderer prints a one-line capability summary: 2D only, no MSAA, no MRT (more than one simultaneous target throws), no anisotropic filtering, SurfaceFormat::Color only, and the unsupported-3D policy in force.

IdentityRaster route3D resultRT / MRT / query
SDL_RENDERERSDL3 texture blitsrefused through HandleUnsupported3DCall (throw by default, warn-and-stub on request)yes / no / no

Every 3D-facing entry point that reaches the renderer (CreateVertexBuffer, CreateIndexBuffer16, the depth and stencil clears and toggles, DrawColoredPrimitives and the indexed variant, CreateOcclusionQuery) calls HandleUnsupported3DCall("SDL_Renderer", …), which throws std::runtime_error with the message SDL_Renderer does not support 3D: <method> unless the device's Unsupported3DGraphicsCallBehavior is WarnAndStub. A custom Effect passed to SpriteBatch::Begin also throws, because there is no shader stage to run it on. None of this is a defect: the refusals are the documented scope, and the family has tests that assert them.

The 2D boundary is at execution, not at every 3D-looking object

"2D-only" does not make every type associated with 3D impossible to construct. CNA separates a renderer-independent description of work from the first operation that asks this renderer to execute it. A VertexDeclaration is only a stride and a list of VertexElement records, with no device ownership and no renderer allocation, so all of its constructor shapes work here; the same declaration becomes unsupported only when a draw consumes it:

VertexDeclaration decl(16, {
    VertexElement(0,  VertexElementFormat::Vector3, VertexElementUsage::Position, 0),
    VertexElement(12, VertexElementFormat::Color,   VertexElementUsage::Color,    0)
});                                   // pure data: works on SDL_RENDERER

static const VertexPositionColor vertices[3]{};
BasicEffect effect(device);
effect.Apply();                       // also fine here: effects are data until a draw
device.DrawUserPrimitives(PrimitiveType::TriangleList, vertices, 0, 1, decl);
// throws std::runtime_error("SDL_Renderer does not support 3D: CreateVertexBuffer")
// (with no effect applied it throws "...no effect has been applied." first)

This is pinned by sdlrenderer_vertexdeclaration_construction_test.cpp (SDL_Renderer_VertexDeclarationConstruction): it constructs every declaration shape, checks the stride and element count it was given, makes the draw call and expects the exception there, then clears and reads back the 2D device to show the refusal left it usable. Portable tools and asset code can therefore prepare vertex layouts without a false renderer failure.

The same split applies to the five stock 3D effects. BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect can be constructed, have their properties set and run Apply(); the primitive draw that follows still throws (sdlrenderer_stock3deffects_apply_test.cpp). RasterizerState and DepthStencilState assignments round-trip as data, but their application is the interface's no-op default: the family overrides neither ApplyRasterizerState nor ApplyDepthStencilState, so a stored CullMode or depth-enable bit never changes a pixel.

Buffers are different because their constructors allocate. VertexBuffer and IndexBuffer validate the public contract first, exactly as XNA does: a zero or negative count throws ArgumentOutOfRangeException before any renderer is asked. A valid count then reaches the renderer factory and fails with the "does not support 3D" message; a 32-bit index buffer never reaches CreateIndexBuffer16: under Reach the public layer throws NotSupportedException first, and under HiDef the family has no 32-bit override, so the interface default throws std::runtime_error ("IGraphicsRenderer::CreateIndexBuffer32: 32-bit index buffers are not supported by this renderer") rather than the "does not support 3D" message; the dynamic variants fail identically to their base types. The cited test still asserts the older delegation message for its two 32-bit cases and has not caught up (sdlrenderer_buffer_construction_throws_test.cpp).

Resources whose 3D-ness the public layer can decide are refused before the renderer: Texture3D checks the false Texture3D capability in its constructor and throws System::NotSupportedException, and the public OcclusionQuery constructor refuses when the device reports no occlusion support. A plain TextureCube may still exist as a description with no native resource, but its public SetData and GetData detect that absence and throw System::NotSupportedException instead of accepting a write or fabricating a transparent face; SDL_Renderer_CubeVolume_SetDataContract and SDL_Renderer_CubeVolume_GetDataContract pin both outcomes.

One positive capability among explicit refusals

The interface's own SupportsCapability default answers true for most members. This renderer overrides it with a single positive claim:

bool SupportsCapability(CNA::GraphicsCapability capability) const override
{
    // The renderer is 2D-only, but SDL_ComposeCustomBlendMode represents the standard
    // Additive preset with independent colour/alpha factors and operations exactly.
    return capability == CNA::GraphicsCapability::AdditiveBlending;
}

All other 18 members, ThreeD included, answer false. The one true answer does not imply arbitrary BlendState support: ApplyBlendState composes an SDL custom blend mode from the separate colour and alpha factors and functions, maps the ten factors from One to InverseDestinationAlpha and all five functions, and throws for BlendFactor, InverseBlendFactor and SourceAlphaSaturation, which have no SDL equivalent. ColorWriteChannels and MultiSampleMask have no SDL API at all and cannot be expressed. Checking the capability before choosing a code path is strictly better than catching the refusal afterwards:

if (device.SupportsCapability(CNA::GraphicsCapability::ThreeD)) {
    DrawModel(model, world, view, projection);
} else {
    // SDL_RENDERER and the other 2D-only renderers take this branch instead of
    // letting CreateVertexBuffer() or a draw throw "does not support 3D".
    DrawFlatPlaceholderSprite();   // illustrative helper
}

Defects that shaped the current code

The renderer was the subject of a line-by-line audit in which each XNA surface was pixel-tested and fixed where it diverged. The defects below are historical; each entry says what the current code does, and each is held by a registered test.

AreaEarlier behaviourCurrent behaviour
Rotation pivot and Begin transformSDL's pivot convention used unmodified; the transform matrix ignoredThe destination is offset by the scaled origin; a non-identity matrix routes through SDL_RenderTextureAffine (details on SpriteBatch batching)
Blend presetsOnly Opaque and Additive handled specially; SpriteBatch::Begin also reset the draw blend mode on every callEvery factor tuple is composed as described above, and Begin no longer overwrites the state the device just applied
Texture filtersFour of the nine TextureFilter values fell back to nearest-neighbourSDL has one scale mode per texture, so the magnification component decides: Linear, Anisotropic, LinearMipPoint and the two MinPointMagLinear* values select SDL_SCALEMODE_LINEAR, the rest nearest
Render targetsA depth clear of a target without depth crashed, and sampling a target as a texture used an unchecked cast between sibling classesTargets report HasRealDepthBuffer false whatever depth format was requested; both texture kinds implement a small SDL-texture interface, and a texture from another renderer is skipped rather than cast
Disposed resources and queriesMissing disposed checks in the sprite path and SetRenderTargets, a dangling pointer after RenderTarget2D::Dispose(), and an occlusion query that could be created with no rendererGuarded (SDL_Renderer_DisposedGuards, SDL_Renderer_OcclusionQueryThrows)
PresentInterval::TwoCollapsed to 1 at run timeSetSwapInterval passes the exact value to SDL_SetRenderVSync and retries with 1 if the driver refuses it; the constructor still maps any positive interval to 1 (see below)

The DrawString flip fix recorded by the same audit was made in the shared SpriteBatch.cpp and applies to every renderer. The audit's closing document, docs/sdl-renderer-2d-completeness.md, is a dated planning snapshot (CNA prose): its pass totals are not a live census, and its blocked cube-and-volume row was later closed by the shared resource-contract repairs, but its custom-effect refusal and its open address-mode decision are still accurate.

Native, emulated and not honoured

XNA featureOn SDL_RENDERER
DepthFormat on targets and back bufferAccepted and echoed back; no depth test exists behind it. Render targets report HasRealDepthBuffer false so shared code can ask instead of assume
MultiSampleCountAccepted, logged once per non-zero request and applied as 0 (ApplyMultiSampleCount)
Swap intervalThe constructor sends swapInterval > 0 ? 1 : 0; later resets send the exact 0, 1 or 2. Direct GraphicsDevice construction with PresentInterval::Two therefore starts at 1 until the next reset (swap interval by family)
Presentation formatsNeither the constructor nor a reset receives the requested colour or depth format; SDL chooses the window output, every CNA texture is RGBA32, and the applied-format hooks keep echoing the request
Full screenA window operation performed by the shared device and platform code; the fullscreen test proves stored state, no exception and continued pixels, not that a display accepted the mode
Mip levelsLevel-zero uploads only: UpdatePixelsLevel throws for any level, and a render target's GetData throws System::NotSupportedException for a level above zero
TextureAddressModeClamp correct by coincidence; Wrap and Mirror not honoured (next section)
ViewportNo SetViewport override: a custom viewport changes stored state and the Viewport projection mathematics but never moves an SDL sprite
ScissorA positive ScissorRectangle installs an SDL clip rectangle and a non-positive size removes it; because there is no rasteriser-state override, the clip applies even when ScissorTestEnable is false (viewport and scissor by family)
Multiple and cube targetsSetRenderTargets throws for more than one target and for a cube face

The scissor behaviour is a divergence from XNA recorded for Known Issues review. The existing rasteriser-state test cannot detect it: it checks that assignment does not throw and that the getters round-trip, then clears one pixel, and never sets a scissor rectangle with the test disabled.

Address modes: clamp by coincidence, wrap not honoured

The sprite path calls SDL_RenderTexture and its rotated and affine variants with a texel-space source rectangle, and that path has one fixed behaviour when the source rectangle leaves the texture. SDL3's SDL_SetRenderTextureAddressMode exists, but it configures SDL_RenderGeometry, a different draw call that this renderer never issues, and wiring it up changed nothing. The family does not override SetSamplerAddressMode, so the requested mode is simply not consulted.

A two-texel case makes the gap concrete. Draw a 2×1 texture, texel 0 red and texel 1 blue, with a source rectangle twice the texture's width, the classic scrolling-background tiling technique (FNA's SpriteBatch does not clamp the source rectangle), and read the destination pixel that corresponds to source position 1.25:

spriteBatch->Begin(SpriteSortMode::Deferred, BlendState::Opaque, &pointClampOrWrap, nullptr, nullptr);
spriteBatch->Draw(redBlue, Rectangle(0, 0, W, H), Rectangle(0, 0, 4, 1), Color::White);  // 2x1 texture
spriteBatch->End();
// read the pixel at x = W * 5 / 8: U = 1.25 texture widths (texel 2.5 of the 4-texel source rectangle),
// one texel past the 2-texel texture: Clamp gives texel 1 (blue), Wrap gives texel 0 (red)

With PointClamp the XNA answer is blue (the last real texel), and SDL's fixed edge behaviour produces exactly that with no production code involved; SDL_Renderer_TextureAddressModeClamp asserts it (sdlrenderer_texture_address_mode_clamp_test.cpp). With PointWrap the XNA answer is red (wrapping back to texel 0), but this renderer produces blue again. The test prints that value for context and does not assert it, so a tiled background that relies on Wrap silently shows the wrong pixel here, without an exception and without an obviously wrong image unless one checks that texel 0 reappears at the wrap boundary.

Three remedies were identified and none was chosen: refuse any Wrap or Mirror request, rewrite the sprite path onto SDL_RenderGeometry (which would also enable Mirror, at considerably larger risk), or refuse only when a source rectangle actually exceeds the texture, the one case in which the modes can differ visibly. The completeness document records the decision as open, and it is recorded for Known Issues review.

Clears and render targets

GraphicsDevice::Clear checks the requested aspects against the device's default clear options before any renderer hook runs. SDL_RENDERER has no depth or stencil plane, so a request that names DepthBuffer or Stencil throws System::InvalidOperationException ("Cannot clear depth or stencil because the device does not have an active depth or stencil buffer"), and Clear(Color) sends colour only. The renderer's own depth and stencil clear hooks are therefore reachable only by code that calls the renderer directly. One registered test has not caught up: SDL_Renderer_ClearOptions_Audit still expects a stencil-only clear to be a silent no-op, an expectation written before the device began refusing unavailable aspects; treat its result with that in mind.

Render-target usage is decided in the shared layer: after a successful bind whose first target is DiscardContents the device clears it to opaque black, while PreserveContents and PlatformContents keep colour, which is unusually direct here because an SDL target texture persists. Rebinding an unchanged target set returns early without clearing again, as in XNA (rebinding is a no-op); descriptions that say a redundant bind clears the target again describe an older revision.

Readback and the presentation mode

GetBackBufferData reaches SdlRenderer::ReadBackbuffer, which reads with SDL_RenderReadPixels. That call works in physical output coordinates, while callers pass logical (virtual-resolution) coordinates. With no target bound, the renderer maps the request through SDL_GetRenderLogicalPresentationRect: if the presented rectangle has the logical size it offsets the region by the rectangle's origin, and if letterboxing or stretching makes the sizes differ it throws ("physical/logical size mismatch … exact-pixel readback unsupported") instead of returning aliased pixels. With a target bound the coordinates address the target texture directly. The result is converted to RGBA32 when SDL returns another format.

For that reason the family's pixel fixtures select PresentationMode::NativeBackBuffer, which makes the logical and physical sizes equal; their source comments record the requirement. The multi-frame sample sdlrenderer_sample_animated_spritesheet_test.cpp shows the pattern. An 8×4 sheet holds a red frame on the left and a green frame on the right; each Update() advances a counter instead of using elapsed time, so the animation does not depend on elapsed time (it still assumes one Update() per Draw() before the check: the fixed-timestep loop can run several updates in one tick on a slow frame); Draw() selects the source rectangle from the counter; and after three updates the test reads the pixel rather than trusting the arithmetic:

void Update(GameTime&) override { if (!done_) { ++frameCounter_; ++updatesRun_; } }

void Draw(const GameTime&) override
{
    const int frameIndex = frameCounter_ % 2;
    const Rectangle srcRect(frameIndex * kFrameSize, 0, kFrameSize, kFrameSize);
    sb_->Begin();
    sb_->Draw(*sheet_, Rectangle(2, 2, kFrameSize, kFrameSize), srcRect, Color::White);
    sb_->End();
    if (updatesRun_ < 3 || done_) return;
    Color px(0, 0, 0, 0);
    Rectangle region(3, 3, 1, 1);
    getGraphicsDeviceProperty().GetBackBufferData(&region, &px, 0, 1);   // expect green
}

Without NativeBackBuffer the frame index could be right while the check samples a scaled coordinate; that failure would concern the fixture, not the animation.

Evidence: what is registered and where it runs

sdl-renderer/examples/CMakeLists.txt registers about eighty SDL_Renderer_* CTests when SDL_RENDERER is the configured renderer and the build is neither Windows nor Emscripten: sprite, font, blend, sampler, texture, target, readback, lifetime and refusal fixtures, the shared unsupported_3d_call_behavior_test.cpp as SDL_Renderer_Unsupported3DBehavior, and five multi-frame samples run as real games (SDL_Renderer_Demo2D_SmokeTest, a bouncing sprite, a keyboard-driven sprite, a two-glyph SpriteFont text and the animated sprite sheet). On Windows, where SDL_RENDERER is the default renderer, none of these register.

Each registration sets SDL_VIDEODRIVER=x11 and DISPLAY=${CNA_TEST_DISPLAY}. CNA_TEST_DISPLAY now defaults to empty, so a test inherits the caller's display; naming the live desktop :0 additionally needs CNA_TEST_ALLOW_LIVE_DISPLAY=ON, and any other value, such as an Xvfb display, is honoured (TestDisplayPolicy.cmake; the private runner is described in Tutorial 125). Unlike SOFTWARE, this renderer needs a display server and a real SDL renderer backend, which under Xvfb is usually Mesa's software OpenGL.

No workflow runs these family tests. The automatic jobs that configure SDL_RENDERER run other suites with it: the Input workflow runs the input label under Xvfb, and the Apple workflow builds on macOS and iOS and runs selected platform and storage suites directly from CnaTests. The ad-hoc cna_xvfb_screenshot_demo target renders a shared rotated-sprite scene for a screenshot under Xvfb and is deliberately not a registered test; an earlier capture of that scene from this renderer is historical evidence only (screenshot evidence).

How to prove the SDL 2D route

  1. Name the configuration: identity, platform (SDL3), the SDL backend the constructor logged, the display the test inherited and the presentation mode.
  2. Select PresentationMode::NativeBackBuffer (or check that the logical presentation rectangle has the logical size) before reading pixels.
  3. Exercise a target bind, draw, unbind and sample-as-texture, and read back a discriminating sprite, not only a clear colour.
  4. Assert the refusal boundary: the execution-time 3D refusal, the unsupported blend factors, the MRT refusal and, if the game relies on them, the address modes and the scissor enable flag.
  5. Record which tests ran with ctest -N and their results; a screenshot alone can look plausible while exercising the wrong presentation rectangle or bypassing the intended target.

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

Maintainer workflow
Fix a renderer bug
Tests and validation
Test architecture: GPU tests