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

Chapter 30 2D and Vector Rasterizers

SDL_RENDERER is CNA’s simplest renderer, its most exhaustively audited, and the one whose scope is narrowest by explicit design: a 2D-only rendering path built directly on SDL3’s own 2D texture-blit renderer, with no 3D pipeline, no programmable shader stage, no depth/stencil buffer, and no multisample antialiasing at all.

It is one of five 2D/vector families in this chapter. Their common output is a raster image; the route to that image ranges from SDL texture blits through two CPU rasterizers to an OpenVG-on-fixed-OpenGL implementation.

Identity Raster route 3D result RT / MRT / query
SDL_RENDERER SDL3 texture-blit API inherited route rejects yes / no / no
SKIA Skia raster or Ganesh mode explicit rejection yes / no / no
BLEND2D CPU Blend2D, streamed through SDL inherited route rejects yes / no / no
OPENVG ShivaVG on fixed-function OpenGL inherited route rejects no / no / no
NANOVG NanoVG on a real OpenGL context explicit rejection no / no / no

30.1 Scope, by design

Every 3D-facing entry point on this renderer — CreateVertexBuffer, DrawColoredPrimitives, and the rest of the 3D draw dispatch from Chapter 19 — throws immediately, with a consistent "SDL_Renderer does not support 3D" message. This is not treated as a bug: a systematic full-suite run confirms exactly thirteen known, expected pre-existing test failures, every one of them this exact throw, matching the renderer’s documented scope precisely. A custom Effect passed to SpriteBatch::Begin(effect) also throws, for the same underlying reason — there is no shader stage to run it on.

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

“2D-only” does not mean that every type normally associated with 3D becomes impossible to construct. CNA deliberately distinguishes a renderer-independent description of work from the first operation that asks this particular renderer to execute that work. A VertexDeclaration, for example, is only a stride plus a list of VertexElement records; it has no GraphicsDevice ownership and no renderer allocation. All four of its public constructor shapes therefore work on SDL_RENDERER. The same declaration becomes unsupported only when it is handed to a real draw call:

1 VertexDeclaration decl(16, {
2 VertexElement(0, VertexElementFormat::Vector3,
3 VertexElementUsage::Position, 0),
4 VertexElement(12, VertexElementFormat::Color,
5 VertexElementUsage::Color, 0)
6 });
7 static const VertexPositionColor vertices[3] = {
8 {Vector3::Zero, Color::White}, {Vector3::Zero, Color::White},
9 {Vector3::Zero, Color::White}
10 };
11
12 // Construction above is pure data. This execution request throws here:
13 device.DrawUserPrimitives(PrimitiveType::TriangleList, vertices, 0, 1, decl);

This is not merely an API-design claim. The renderer’s dedicated regression test constructs the declaration, confirms that it retains the supplied stride and two elements, then makes the DrawUserPrimitives call and verifies that the failure happens there rather than while declaring the layout. It finally clears and reads back the 2D device successfully, proving the exception did not leave renderer state unusable. This boundary lets portable tools and asset code prepare vertex layouts without a false renderer failure, while still refusing the first request that would require an SDL renderer 3D pipeline it does not have.

The same split applies to the five stock 3D effects. BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect, and SkinnedEffect can all be constructed, have ordinary properties assigned, and run Apply() on this renderer. They are data/configuration objects until a primitive draw is attempted; that draw still throws. In particular, the effect test assigns a real Texture2D where applicable but deliberately leaves EnvironmentMapEffect’s TextureCube pointer null, separating this verified lifecycle rule from the still-blocked question of cube-texture construction.

There is a third, easy-to-miss case: RasterizerState and DepthStencilState assignments also round-trip as data, but their renderer application is an intentional no-op. SDL_Renderer inherits the interface’s no-op ApplyRasterizerState and ApplyDepthStencilState defaults because neither state can affect a 2D texture-blit draw. Code can therefore store and inspect a desired CullMode or depth-enable bit without an exception; it must not infer that either state will change pixels. By contrast, VertexBuffer and IndexBuffer constructors immediately call their renderer factories and fail before their own argument validation runs. On this renderer even a zero or negative requested count therefore produces the same explicit “does not support 3D” runtime error, rather than an ArgumentOutOfRangeException. The ordering is observable and tested, so callers that need portable validation should validate their input before asking SDL_Renderer to allocate a 3D buffer.

30.1.2 A narrow positive capability among explicit refusals

Chapter 19’s GraphicsCapability mechanism defaults to answering true for everything unless a renderer overrides it. Reading SdlRenderer.hpp directly shows this renderer instead makes one narrow positive claim and refuses every broader 3D/resource capability:

1 bool SupportsCapability(GraphicsCapability capability) const override
2 {
3 // SDL_ComposeCustomBlendMode represents the standard Additive preset.
4 return capability == GraphicsCapability::AdditiveBlending;
5 }

Of the current thirteen values, only AdditiveBlending is true. SDL’s custom blend mode can express that preset’s independent colour and alpha equations; this does not imply arbitrary BlendState support. ThreeD, complete depth/stencil, standalone stencil, MSAA, MRT, anisotropy, wireframe, queries, custom effects, volume storage, multistream input and instancing all return false. The important property is not a blanket answer but an explicit, auditable boundary: adding the legitimate 2D blend claim did not reopen any 3D promise.

1 if (device.SupportsCapability(GraphicsCapability::ThreeD)) {
2 DrawModel(model, world, view, projection);
3 } else {
4 // Reached on SDL_RENDERER (and CANVAS) -- checking first here is strictly better
5 // than letting CreateVertexBuffer() or DrawColoredPrimitives() throw
6 // "SDL_Renderer does not support 3D" and catching it after the fact.
7 DrawFlatPlaceholderSprite();
8 }

30.2 Recorded SDL Renderer audit and later repairs

The recorded 65-item technical audit found and fixed fifteen defects across most of the SpriteBatch surface. Its closing document is historical; later repairs described below supersede parts of that snapshot.

  • SpriteBatch (2 bugs). SDL_RenderTextureRotated’s rotation-pivot convention did not match XNA’s own — fixed by offsetting the destination rectangle. The transformMatrix argument to Begin() was silently ignored in its entirety — fixed via a new path using SDL_RenderTextureAffine, used only for a genuinely non-identity transform (Chapter 13 covers both in full).

  • SpriteFont (1 bug, shared across every renderer). The SpriteEffects flip bug described in Chapter 13 — fixed once, in the shared SpriteBatch.cpp, benefiting every renderer at once rather than being an SDL_Renderer-specific fix.

  • BlendState (2 bugs). Only Opaque and Additive were specially handled; every other preset mapped incorrectly. Separately, SpriteBatch::Begin() was unconditionally clobbering whatever blend mode had already been set, every single call.

  • SamplerState (1 bug). Four of nine TextureFilter values were silently downgraded to nearest-neighbor sampling rather than mapping to their real SDL_ScaleMode equivalent.

  • RenderTarget2D (4 bugs). A shared depth-clear code path crashed outright when asked to clear the depth buffer of a render target with DepthFormat::None (i.e., no depth buffer at all, which is every render target on this 2D-only renderer). A second bug performed an unchecked, unsafe downcast between two sibling renderer classes when sampling a render target as an ordinary texture — a genuine memory-safety hazard, not merely a logic error. The requested DepthFormat itself is honestly acknowledged as emulated: it is silently accepted and echoed back, but no functional depth test exists behind it at all on this renderer. A new HasRealDepthBuffer() check was added specifically so calling code can ask, rather than assume. The persistent SDL target texture also makes shared RenderTargetUsage behavior unusually direct: Discard is black-cleared on bind, while Preserve and Platform retain color. The strong usage test proves the first two with pixels, but its “every bind” wording omits CNA’s divergence from FNA: a redundant bind clears again here, while FNA returns early.

  • Present timing (1 fixed runtime bug, 1 remaining construction asymmetry). Runtime PresentInterval::Two used to collapse silently to One. SetSwapInterval() now sends the exact value 2 to SDL_SetRenderVSync() and falls back to 1 if the driver rejects it. The constructor still uses swapInterval > 0 ? 1 : 0, despite SDL3 supporting integer intervals there too. This usually stays hidden because Game constructs with the default 1 and later requests arrive through the repaired reset path; direct GraphicsDevice construction with Two still loses the distinction. ASCII wraps this same renderer and inherits both behaviors.

  • Presentation formats (stored, not selected). Neither the constructor nor a later reset receives the requested backbuffer/depth format: the factory passes only window, logical-presentation, and swap-interval fields. SDL chooses the renderer’s window output, while every CNA-created SDL texture is RGBA32 and no depth/stencil attachment exists. Fullscreen is different: shared device code still makes SDL’s window transition request before the renderer’s empty format hook. The dedicated fullscreen test therefore proves stored state, no throw, and continued pixels — not display acceptance under Xvfb. ASCII inherits this complete split too.

  • Disposed-resource guards (3 bugs). A missing disposed-texture check inside the internal sprite-push path; a missing equivalent check in SetRenderTargets; and a dangling renderer pointer left behind after RenderTarget2D::Dispose().

  • OcclusionQuery (1 bug). Construction previously succeeded silently even with a null renderer — now throws, consistent with the 2D-only-renderer pattern of failing loudly at construction rather than producing a query object that can never do anything.

30.3 One blocked sampler decision, and one resource gap that later closed

One renderer-local architecture decision remains open; the former resource-construction item no longer does:

TextureAddressMode::Wrap / Mirror via SpriteBatch.

SDL_RenderTexture’s own srcrect handling has exactly one fixed, clamp-like behavior at the edges. SDL3 does expose SDL_SetRenderTextureAddressMode, but it only affects SDL_RenderGeometry — an entirely different draw call than the one this renderer’s Draw() actually issues. Three concrete options were identified and none chosen: throw unconditionally whenever Wrap / Mirror is requested, rewrite Draw() onto SDL_RenderGeometry project-wide, or a hybrid that only throws when the source rectangle actually exceeds the texture’s bounds (the common case where it does not would keep working, coincidentally, under the existing clamp-like behavior).

Texture3D / TextureCube: former silent-resource gap, now closed.

Texture3D now consults the false Texture3D capability and throws NotSupportedException during construction. A plain TextureCube may still exist as a renderer-independent description with no native resource, but its public SetData/GetData paths detect that absence and throw instead of accepting a write or fabricating a transparent-black face. Shared read/write contract fixtures pin both outcomes on this renderer. The old 94-test blast-radius note explains why the first proposed blanket-construction change was deferred; it is not the current observable contract.

30.3.1 What “clamp-like by coincidence” means in pixels

The blocked TextureAddressMode::Wrap / Mirror decision above is easy to read as an abstract API gap; a concrete pixel case makes it precise instead. Take a 2×1 texture, texel 0 red and texel 1 blue, and draw it with a source rectangle twice the texture’s own width — source texel range [0,4] against a 2-texel-wide texture, the classic XNA scrolling-background tiling technique, which FNA’s real SpriteBatch never clamps sourceRectangle to the texture’s own bounds to prevent:

1 Texture2D redBlue(getGraphicsDeviceProperty(), 2, 1);
2 const Color pixels[2] = {Color::Red, Color::Blue};
3 redBlue.SetData(pixels, 2);
4
5 Rectangle oversizedSource(0, 0, 4, 1); // spans texel [0,4] on a 2-texel-wide texture
6 spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::Opaque, &pointClamp, nullptr, nullptr);
7 spriteBatch_->Draw(redBlue, destRect, oversizedSource, Color::White);
8 spriteBatch_->End();

Reading back the destination pixel corresponding to source position 1.25 (three-quarters of the way into the oversized range, past the real 2-texel-wide texture entirely) is where the “clamp-like, by coincidence” finding becomes a concrete, checkable number rather than a qualitative claim: with PointClamp requested, the correct XNA answer is blue — clamped to the texture’s last real texel — and SDL_RenderTexture’s one fixed edge behavior genuinely produces exactly that, with zero production code written to make it happen. Requesting PointWrap at the identical source position is where the still-open gap becomes equally concrete: the XNA-correct answer for a wrapped read at position 1.25 is red (wrapping back around to texel 0), but this renderer’s Draw() produces blue again — the same fixed clamp-like behavior applies regardless of which TextureAddressMode was actually requested, since (per the finding above) SDL_RenderTexture has no address-mode parameter reachable from this specific draw path at all. A game relying on horizontally-scrolling tiled backgrounds — a common enough technique that this is not a hypothetical edge case — gets the wrong pixel on this renderer today, silently, with no exception and no visual artifact obviously identifiable as “wrong” unless you already know to look for texel 0 failing to reappear at the wrap boundary.

30.4 Native and emulated behavior

Beyond the DepthFormat case above, TextureAddressMode::Clamp is flagged as working “by coincidence” — it matches SDL_RenderTexture’s own fixed out-of-bounds behavior, not because any real sampler-state value is being honored. MultiSampleCount is accepted and ignored, always reporting zero. Fullscreen toggling round-trips correctly at the API level, but the audit is explicit that genuine OS-level fullscreen state cannot actually be verified inside this project’s own headless Xvfb test environment. ClearOptions::DepthBuffer / Stencil expose a shared-routing distinction from the rest of this chapter’s 3D methods. The renderer’s six direct combination hooks do call ThrowNo3D, but public GraphicsDevice.Clear first sees SupportsDepthStencil()==false and masks both bits away. A depth/stencil-only request is therefore an FNA-faithful silent no-op; adding Target still clears colour. The two SDL-specific tests that expect public throws predate that mask and are stale; they do not establish that the direct hooks are reachable. Chapter 12 and §19.4.2 separate the shared and renderer layers in full. Five real, multi-frame compatibility samples — a 2D demo, a bouncing-sprite physics scene, a keyboard-driven sprite, a two-glyph SpriteFont test, and an animated spritesheet — are registered as genuine ctest entries rather than left as manual verification, giving this renderer end-to-end coverage beyond its unit-level pixel tests.

Viewport and scissor expose a more subtle 2D-state boundary. There is no SetViewport() override, so a custom public viewport changes stored state and Viewport’s own projection mathematics but never remaps an SDL sprite. Scissor does reach SDL: a positive ScissorRectangle installs a renderer clip, and a non-positive size disables it. However, the rasterizer-state hook is also absent. The clip is consequently active solely because the rectangle is non-empty, even when RasterizerState.ScissorTestEnable == false. The existing rasterizer construction test cannot detect this: it verifies no-throw assignment and getter round trip, then clears one pixel, but never sets a scissor rectangle or asks whether drawing outside it survives. This is the precise distinction the inherited fourteen-product audit matrix in §19.4.3 preserves.

30.5 Reading the feature matrix for this renderer

The repository’s feature-matrix document is a dated planning snapshot, not a live test census. Its historical custom-Effect refusal remains accurate, but its two blocked rows do not: only Wrap/Mirror still needs the renderer-architecture decision described above; cube/volume silent success was closed by the shared resource-contract repairs. Current claims in this chapter therefore come from the registered renderer tests and pinned source, not from the snapshot’s old pass total or status count.

30.6 Xvfb screenshot evidence

Screenshots credited to CNA’s SOFTWARE renderer (Chapter 12) need no display server. SDL_RENDERER, by contrast, creates an SDL3 window and submits SDL_RenderTexture calls through an OpenGL context. The retained campaign therefore used Xvfb with Mesa’s llvmpipe. Earlier feasibility notes had identified this route but left it untested; CNA’s CNA_TEST_DISPLAY cache variable already permits a virtual display such as :99 in place of the default :0.

Building this renderer (cmake -B build-sdlrenderer -DCNA_GRAPHICS_RENDERER=SDL_RENDERER) and running the screenshot demo under xvfb-run exercised the display and capture path. The demo reuses the same scene as the golden-image regression test modules/renderers/easygl/examples/easygl_spritebatch_rotation_golden_test.cpp, also used in Chapter 13: a 100×100 Texture2D whose top-left 20×20 block is red and the remainder blue, drawn through SpriteBatch rotated 90 degrees (MathHelper::PiOver2) around origin (100,100) — the source’s own bottom-right corner — then captured with the same SaveBackBufferScreenshotEXT helper the SOFTWARE renderer screenshots use:

1 xvfb-run -a --server-args="-screen 0 1024x768x24" \
2 env -u WAYLAND_DISPLAY ./build-sdlrenderer/cna_xvfb_screenshot_demo
SDL Renderer screenshot of a SpriteBatch rotation fixture. A red marker rotated ninety degrees around the source bottom-right origin lands at the destination rectangle's top-right corner.
Figure 30.1: Output of the rotate-around-origin SpriteBatch scene rendered by SDL_RENDERER under Xvfb, without a physical GPU or desktop. The red marker lands in the destination rectangle’s top-right corner, the expected result of a 90-degree rotation about the source’s bottom-right corner.

The marker position matches the geometric derivation used by the golden test (Chapter 13). The retained artifact closes the screenshot-infrastructure feasibility item for a renderer that requires a virtual display.

30.7 Pixel readback and presentation mode

The pixel fixtures share one prerequisite, documented in their source comments:

Requires PresentationMode::NativeBackBuffer: SDL_RenderReadPixels operates in physical output coordinates, while this renderer’s default presentation mode (FixedHeightDynamicWidth) does not map logical pixels 1:1 to physical ones.

GetBackBufferData() delegates to SDL_RenderReadPixels, which reads physical output coordinates. FixedHeightDynamicWidth may letterbox logical output, so a logical coordinate does not necessarily name the same physical pixel. NativeBackBuffer makes the two sizes equal. The fixtures select it explicitly; without that precondition, a plausible coordinate check can sample the wrong pixel.

30.7.1 Animated sprite-sheet frame selection

modules/renderers/sdl-renderer/examples/sdlrenderer_sample_animated_spritesheet_test.cpp, the fifth entry in a five-sample compatibility suite, exercises a different route from the single-frame checks above. These samples use multiple Update() calls. The fixture covers the SpriteBatch::Draw sourceRectangle parameter, re-selected every Update() call to animate through a spritesheet. The deterministic sheet is an 8×4 texture, left 4×4 solid red (frame 0), right 4×4 solid green (frame 1), with a fixed per-Update() frame-index increment instead of elapsed-time-based timing, so the outcome never depends on wall-clock frame speed:

1 void Update(GameTime&) override
2 {
3 if (done_) return;
4 ++frameCounter_;
5 ++updatesRun_;
6 }
7
8 void Draw(const GameTime&) override
9 {
10 // ...
11 const int frameIndex = frameCounter_ % 2;
12 const Rectangle srcRect(frameIndex * kFrameSize, 0, kFrameSize, kFrameSize);
13 const Rectangle destRect(2, 2, kFrameSize, kFrameSize);
14
15 sb_->Begin();
16 sb_->Draw(*sheet_, destRect, srcRect, Color::White);
17 sb_->End();
18 // ...
19 }

After exactly three Update() calls, frameCounter_ % 2 evaluates to 1, so the animation should have settled on the green frame. The test then reads the rendered pixel instead of checking the frame-index arithmetic alone:

1 Color px(0, 0, 0, 0);
2 Rectangle region(3, 3, 1, 1);
3 dev.GetBackBufferData(&region, &px, 0, 1);
4 check(px.getRProperty() <= 15 && px.getGProperty() >= 240 && px.getBProperty() <= 15,
5 "frame 1 (green) reached the rendered output");

Without PresentationMode::NativeBackBuffer, the frame index can be correct while the pixel check samples a scaled or letterboxed coordinate. That failure would concern fixture setup, not animation.

30.8 SKIA: a bounded contract written down before expansion

SKIA is the only renderer whose dependency is neither a system library nor fetched by the project. Configuration requires a separately built Skia artifact through CNA_SKIA_ROOT and CNA_SKIA_BUILD_DIR; absence is fatal.

CNA_SKIA_MODE selects raster or Ganesh inside the family. It is not a second public renderer identity. Both modes retain the same CNA contract and must be named separately only when their execution evidence differs.

The implementation has a deliberately bounded 2D surface. It owns CPU-raster targets and explicitly rejects 3D, MRT, MSAA, and occlusion queries. Optional SkSL extensions widen specific shader-like operations without turning the renderer into a general ShaderEffect route. The companion capability matrix explains each refusal rather than relying on a default-true switch; this is one of the strongest capability-documentation patterns in the repository.

30.9 BLEND2D: CPU vectors presented as an SDL texture

BLEND2D fetches pinned Blend2D and AsmJit revisions unless root overrides are supplied. It rasterizes on the CPU, uploads the resulting pixels to a streaming SDL texture, and uses SDL only for the final presentation edge.

The family implements 2D targets. It has no MRT, query, or 3D path; inherited extended draws reach the explicit refusal. Per-channel color-write masks are not natively expressible in its current compositor, a useful example of a state property that can exist in the public object without a portable 2D mapping.

The evidence should separate CPU raster output from streaming-texture presentation. Direct buffer inspection can prove shape and blend math, while only a real SDL present/readback path can prove pitch, upload format, and final channel order.

30.10 OPENVG: a vector API on a historical GL substrate

OPENVG uses ShivaVG, pinned to its only upstream commit, plus a local leak patch, a synthesized configuration header, a GL type shim, and GLU. The resulting OpenVG 1.1 path runs over fixed-function OpenGL and needs a real compatible context.

Its capability policy returns false for all 13 flags. That is conservative but internally coherent: the renderer supplies its 2D drawing surface without promising the broader GPU features those flags describe. It has no render-target factory, MRT, queries, shaders, or 3D; effect-aware draws inherit the common route and reject.

30.11 NANOVG: immediate vectors without off-screen storage

NANOVG fetches a pinned upstream revision and uses NanoVG’s OpenGL backend for paths, images and 2D composition. It is deliberately 2D-only: there is no vertex/index-buffer 3D pipeline, programmable custom effect, depth/stencil contract, MRT or query. Although NanoVG ships an optional framebuffer helper, CNA does not include it in this family’s scope; RenderTarget2D construction therefore fails transactionally rather than returning a resource that cannot be bound.

The family reports the relevant capabilities false and refuses anisotropic sampling, wireframe and non-default color-write masks it cannot preserve through NanoVG’s flush. Its tests cover real context creation, images, scissor, transformations, blending and refusal behavior; a dedicated workflow declares NanoVG automation. These are source-visible gates here, not a newly executed pixel campaign by this edition.

30.12 Five ways to prove a 2D renderer

For SDL Renderer, observe the actual SDL target and presentation mode. For Skia, name raster or Ganesh and test the bounded SkSL path separately. For Blend2D, compare the CPU pixels before and after SDL upload. For OpenVG, capture the GL implementation and context profile alongside the vector result. For NanoVG, name the OpenGL context and distinguish immediate vector output from absent off-screen-target storage. A single screenshot can look identical across all five while exercising materially different ownership and delivery paths.

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