DIRECT2D and GDI: two Windows 2D delivery stacks

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 the family sources, CMake registrations and the two Windows workflow files at 009d40f5; nothing was built or run, no Windows or Wine session was observed, and the depth-format caveat is a code reading only.

DIRECT2D and GDI are CNA's two Windows-only 2D renderers. They share a platform gate and a 2D scope and nothing else: DIRECT2D records native ID2D1 drawing commands on a private Direct3D 11 device, while GDI rasterises every sprite on the CPU with code borrowed from the SOFTWARE family and then copies the finished frame into a Win32 window. This page explains both delivery stacks at the level of their source: what each draws, what each refuses and how, how each presents, and how much the registered tests and the Windows workflows actually prove. It is for anyone choosing a Windows 2D path, reading a Direct2D or GDI test result, or changing either family.

Two stacks, one 2D scope

Both identities are hard-gated to CMAKE_SYSTEM_NAME=Windows (a native MSVC or MinGW build, or a MinGW cross-build from Linux) by RendererSelection.cmake, both report ThreeD false, and both are declared Supported in CNA's own maturity table (a declaration, not a measurement). Their descriptors ask for a plain window (RendererWindowKind::Plain, a window and the video subsystem), and each constructor insists on a Win32 native window handle, so they run on the SDL3 or WIN32 platform on Windows. The common user-level table is in Graphics renderers: the Windows set; the differences that matter are these.

QuestionDIRECT2DGDI
Who draws the pixelsDirect2D 1.1 (ID2D1DeviceContext) on a private Direct3D 11 device, hardware first, WARP as fallbackCNA's own CPU 2D rasteriser (the SOFTWARE family's 2D translation units), no GPU API at all
How a frame reaches the windowA logical-size target bitmap drawn into a flip-model DXGI swap chain, then IDXGISwapChain::PresentThe CPU frame copied with SetDIBitsToDevice or StretchDIBits
Capabilities answering trueAnisotropicFiltering onlyStencilBuffer, WireFrame, MultiSampleAntiAliasing, AdditiveBlending
RenderTarget2D / MRT / occlusion queryyes (Color, no depth, no mips) / no / noyes (colour, stencil, optional mips) / no / no
How 3D is refusedThrough the shared HandleUnsupported3DCall, so WarnAndStub is honouredSystem::NotSupportedException from its own methods, always
Registered tests on a Linux cross-buildYes, run under Wine or Proton with their built-in Direct2DNone: every GDI_* registration is skipped when cross-compiling

DIRECT2D: native Direct2D over a private Direct3D 11 device

DIRECT2D is not a reduced mode of DIRECTX11. It has no shader stage and never compiles HLSL; every sprite becomes a Direct2D bitmap, image-brush or effect-graph operation. The whole family lives in Direct2DRenderer.cpp and its header Direct2DRenderer.hpp.

Device, swap chain and the WARP fallback

CreateDeviceResources calls D3D11CreateDevice with D3D11_CREATE_DEVICE_BGRA_SUPPORT and feature levels 11_1 down to 10_0, first on the hardware driver and, if that fails, on WARP, Microsoft's software Direct3D device (the source comment names VMs and remote-desktop sessions without a hardware adapter as the reason). Three environment switches exist for diagnosis: CNA_DIRECT2D_FORCE_WARP skips the hardware attempt, CNA_DIRECT2D_DEBUG_LAYER adds D3D11_CREATE_DEVICE_DEBUG, and CNA_DIRECT2D_DIAGNOSTICS prints the driver type, feature level and adapter ids to standard error. No application code ever sees this Direct3D 11 device.

Presentation uses a two-buffer DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL swap chain in DXGI_FORMAT_B8G8R8A8_UNORM, created with CreateSwapChainForHwnd and with DXGI's Alt+Enter handling disabled. Drawing never targets the swap chain directly: it goes to a logical target bitmap the size of the virtual resolution. Present clears the back buffer to black, draws the logical bitmap through the presentation matrix (letterbox, stretch and the other presentation modes) with linear interpolation, and then calls Present on the swap chain with the stored swap interval as DXGI's sync interval, so an interval of 2 waits for two vertical blanks. The constructor accepts intervals 0 to 2 only. Present throws System::NotSupportedException while a RenderTarget2D is still bound, instead of presenting the wrong surface.

Formats and the construction-time refusals

The constructor refuses, by name, every presentation request the stack cannot honour: a back-buffer format other than SurfaceFormat::Color, any depth format other than DepthFormat::None, and a multisample count above 1 all throw System::NotSupportedException; a reset re-checks the formats through UpdatePresentationFormatEXT. After construction the applied-format hooks report Color and None, so PresentationParameters describes what exists (see formats and the reset rollback).

⚠

By reading the source at this snapshot: the depth format a GraphicsDeviceManager requests is its PreferredDepthStencilFormat, whose default is Depth24, but on the Game path that request never reaches DIRECT2D unnormalised: the device is constructed with PresentationParameters' default DepthFormat::None, and GraphicsDevice::Reset (like SetPresentationParameters) first passes the request through the renderer's GetAppliedDepthStencilFormatEXT, which answers None for Direct2D, before UpdatePresentationFormatEXT re-checks it. A game that targets DIRECT2D therefore does not need to set DepthFormat::None itself. This was not executed.

Render targets follow the same rule: CreateRenderTarget2DEXT accepts only Color, DepthFormat::None and a sample count of 0 or 1, and it refuses a mipmapped target outright because the former generated-mip path for non-power-of-two sizes was not spatially correct. Only one target can be bound: SetRenderTargets throws for a count above 1 ("MRT is unsupported") and for a cube face, and it refuses a target created by another renderer or by another GraphicsDevice. Rebinding validates the new target's device generation before any state changes, so a rejected bind leaves the logical and native targets consistent.

At the data boundary, CNA's tightly packed RGBA bytes become BGRA for Direct2D (CopyRgbaToTightBgra) and come back as RGBA on readback (CopyBgraToTightRgba); both reject a null pointer, a pitch shorter than one row and dimensions whose byte count would overflow, before allocating. Texture and target sizes are bounded by the Direct3D 11 dimension ceiling and then by the device's own GetMaximumBitmapSize(), which is also what GetMaxTextureDimension reports.

Blend states: an exact composite or a refusal

Direct2D has no general source/destination blend-factor interface, so BlendStateToDirect2DBlendMode maps an exact factor tuple to a native composition and refuses everything else:

RequestResult
BlendState::Opaquebounded source copy
AlphaBlend, NonPremultipliedsource-over (the non-premultiplied source is converted, see below)
BlendState::Additive (SourceAlpha/One) and One/OneSystem::NotSupportedException: Direct2D's additive composite cannot treat texture and RenderTarget2D sources consistently, which is why AdditiveBlending answers false
Eight symmetric Porter-Duff factor pairs with BlendFunction::Add (destination-over, source-in, destination-in, source-out, destination-out, source-atop, destination-atop, xor)the matching D2D1_COMPOSITE_MODE through DrawImage
Different colour and alpha factors, or any function other than Addstd::runtime_error naming the limitation

The Porter-Duff results are tested against CompositePorterDuffReference, which computes the expected pixel from the operator algebra alone and deliberately never consults the mapping it checks, so a test cannot pass by agreeing with itself.

Sampling, mips and the CPU fallback

Each TextureFilter becomes a Direct2D interpolation mode for the in-plane filter (the mixed min/mag filters pick the minification or magnification half depending on whether the draw minifies), and TextureAddressMode Wrap, Clamp and Mirror map to D2D1_EXTEND_MODE. Direct2D has no implicit mip chain for CNA's per-level bitmaps, so a filter whose mip component is linear is resolved by a CPU path that blends the two bracketing levels; the fractional level of detail comes from the complete batch and presentation transform, including rotation and shear.

Tint and non-premultiplied sources use the ColorMatrix and Premultiply built-in effects when the device provides them; the renderer probes both. When an effect is missing, an ordinary Texture2D falls back to CPU-prepared sprite pixels, but a RenderTarget2D source has no CPU copy to fall back to and throws System::NotSupportedException with the workaround in the message (draw with Color::White from a premultiplied source, or copy the target to a texture first). Transient bitmaps, effects and brushes created during a batch are retained until that chunk's EndDraw, so a texture disposed after SpriteBatch::End and before Present is not read after release, and bounded caches keep a long batch from growing without limit.

Device loss and recovery

IsDeviceLossHResult treats D2DERR_RECREATE_TARGET and the DXGI device-removed, reset, hung and driver-internal-error codes as a lost device. Every detection site funnels into one recovery routine that raises the public DeviceLost, DeviceResetting and DeviceReset events once each, in XNA's order, releases and recreates the device, and then rebuilds the textures and targets that were registered while context recovery was enabled. A resource created while recovery was disabled is not rebuilt; its next use throws a message saying it belongs to a lost device. A loss detected at Present throws after recovery with the instruction to redraw the frame.

What a 3D call does

The family overrides Ensure3DSupported and every 3D factory, clear and draw hook with HandleUnsupported3DCall. Under the default Unsupported3DGraphicsCallBehavior::Throw the effect-aware draws therefore end in the family's explicit "does not support 3D" refusal instead of silently drawing a sprite-coloured triangle after a stock-effect packet was discarded; under WarnAndStub the call logs once and returns a no-op object (the occlusion-query factory returns a no-op query). The policy is explained in the unsupported-3D policy.

GDI: a Win32 presenter over a private CPU 2D core

Composition, not inheritance

GdiRenderer derives directly from IGraphicsRenderer and privately owns a GdiSoftware2DCore, a small adapter that does inherit the CPU SoftwareRenderer but exposes only the back buffer needed for presentation and a raster-bounds callback used for damage tracking (GdiRenderer.cpp). No pointer or reference to that complete Software renderer escapes, and GdiRenderer forwards only its reviewed sprite, texture, target and state operations, so a 3D method added to Software later cannot silently appear in GDI's public vtable.

The build mirrors that boundary. Selecting GDI enters the software module, whose CMakeLists.txt publishes a reviewed list of CPU-2D translation units as CNA_GDI_SOFTWARE_SOURCES; the GDI archive is those units plus the GDI-owned presentation and configuration files, linked with gdi32 (gdi/CMakeLists.txt, which prints the archive's unit count at configure time). Because the shared units are compiled with CNA_SOFTWARE_2D_ONLY, GDI and SOFTWARE cannot be linked into one binary; the internals of that split are on Software renderer internals.

What the CPU core provides

The capability switch answers true for exactly four members, each backed by real CPU work: StencilBuffer (a standalone 8-bit stencil plane that is always present, usable for 2D masks), WireFrame (sprite quads are genuinely rasterised as outlines), MultiSampleAntiAliasing and AdditiveBlending. DepthStencilBuffer stays false because GDI has no depth plane at all. Multisampling is opt-in and has one honest configuration: a request for exactly 4 allocates four samples per pixel on a 2×2 grid, any other request stays single-sampled, and the applied count is what is reported back.

CreateRenderTarget2D builds a CPU target with colour, the stencil plane and an optional mip chain generated on unbind, and deliberately no depth attachment and no MSAA storage, whatever depth format was requested. One target can be active; a multi-target set, a cube face or a non-zero array slice throws. A SpriteBatch custom effect is accepted only if it is the fixed CPU ColorMatrixEffect (see ColorMatrixEffect); any other effect is rejected rather than accepted and ignored. ClearStencil and ClearColorAndStencil work; every clear variant that names depth throws.

Refusals

Cube textures, volume textures, cube render targets, ShaderEffect programs, occlusion queries, vertex and index buffers, the depth toggles and every 3D draw call ThrowUnsupportedFeature, which raises System::NotSupportedException with the text "GDI (Win32 2D) does not support …". These methods do not route through HandleUnsupported3DCall, so WarnAndStub never turns them into warnings.

Presentation: DIBs, dirty bands and filters

GdiPresentation.cpp plans each present. By default the whole frame is copied with SetDIBitsToDevice. When the presentation mode scales the frame, StretchDIBits is used instead, with either nearest-neighbour (COLORONCOLOR) or HALFTONE stretching. Copying only the damaged band of rows is an opt-in optimisation, and a full copy is still forced after a window repair, a full invalidation or any scaling. Three environment settings are read once per renderer by GdiConfiguration.cpp; invalid values keep the safe default and are reported together in one diagnostic line:

VariableValuesEffect
CNA_GDI_PRESENT_FILTERnearest (default), halftoneStretch filter for a scaled presentation
CNA_GDI_DIRTY_PRESENTATIONswitch, off by defaultCopy only the damaged rows when nothing forces a full copy
CNA_GDI_DWM_FLUSHswitch, off by defaultCall DwmFlush after presenting when the compositor provides it

SetSwapInterval is an empty override: GDI has no display-interval control, and DwmFlush is the only pacing it offers.

Why shared code does not make GDI and SOFTWARE equivalent

SOFTWARE runs with no window and returns pixels from its own buffers; GDI must additionally create, update, invalidate and present a Win32 bitmap. A CPU raster test can prove the shared arithmetic while missing a stride, channel-order, invalidation or lifetime defect at the GDI boundary; a presentation smoke test can show a bitmap without distinguishing the stencil semantics beneath it. GDI also refuses what SOFTWARE implements (3D draws, cube and volume resources, depth), so a Software result is never a GDI result.

Evidence and the release boundary

DIRECT2D tests and workflow

direct2d/examples/CMakeLists.txt registers four renderer tests when DIRECT2D is the configured renderer: Direct2D_Smoke (an HWND-backed device, a readback of the clear colour and one point-sampled sprite), Direct2D_2DParity (the public Texture2D, SpriteBatch and RenderTarget2D round trip with pixel readback), Direct2D_Lifetime (transient resources across readback, target switches, resize and recovery) and Direct2D_Soak (2,000 resize, recovery and target-switch cycles by default, CNA_DIRECT2D_SOAK_CYCLES for more, with a working-set leak check). UnitTests.cmake adds Direct2D_Unit, which runs the device-free GoogleTests in Direct2DRendererTests.cpp: device-loss classification, the blend mapping, the capability contract (its test name still says "AllThirteen", from before the enum grew to 19), pixel conversion, mip policy and the Porter-Duff reference.

On a MinGW cross-build each registration runs through a wrapper chosen by CNA_DIRECT2D_TEST_RUNTIME (WINE or PROTON). The Wine route sets CNA_D3D11_SKIP_DXVK_GATE=1: Direct2D must use the Wine environment's own built-in d2d1, d3d11 and dxgi, and replacing them with DXVK would change the stack under test (the gate itself is explained in Direct3D evidence). Because Wine's Direct2D registers no ColorMatrix effect and ignores the bounded-copy and Porter-Duff composite modes, the parity test also receives CNA_DIRECT2D_SKIP_RENDER_TARGET_DECORATION=1 and CNA_DIRECT2D_SKIP_ADVANCED_BLEND=1 there; those matrices are reserved for a real Windows runtime. A separate executable, cna_corpus2d_direct2d, dumps the renderer's half of a cross-renderer 2D corpus for comparison with EasyGL and is deliberately not a CTest.

The native tier is the Direct2D leg of d3d-windows-ci.yml, which runs on manual dispatch only. It checks the plan against its evidence, self-tests the debug-log parser, runs the whole Direct2D label with the Direct3D 11 debug layer and diagnostics, repeats Direct2D_Lifetime on forced WARP, and fails on any live object outside the parser's whitelist.

The renderer's own release gate is stricter than any workflow. docs/direct2d-release-gate.md (CNA prose) defines it as the output of python3 scripts/validate_direct2d_plan.py --release-gate, and two of its criteria need artefacts that no hosted run can produce: a recorded native x64 Windows run naming the Windows build, the d2d1.dll/d3d11.dll/dxgi.dll versions and the adapter driver, with results for the branches Wine cannot exercise; and a physical presentation capture on a real monitor at non-96 DPI. Neither evidence file exists at this snapshot, so the gate is blocked by construction; a green workflow run, even with a hidden window on a hosted runner, does not satisfy it.

GDI tests and workflow

gdi/examples/CMakeLists.txt builds seventeen test programs and a benchmark, and registers nineteen GDI_* CTests (smoke, 2D regression, ColorMatrixEffect, public stencil, public API, applied state, unsupported features, dirty damage, repaint invalidation, GDI_PresentationOracle, window metrics, framebuffer and texture allocation, the MSAA contract, the presentation-mode and device-context release transactions, and the presentation-configuration program three times, as GDI_Presentation_Default, GDI_Presentation_Dirty and GDI_Presentation_Halftone with the matching environment) only inside if(NOT CMAKE_CROSSCOMPILING). A Linux cross-build therefore produces the executables but registers none of them, and nothing about GDI runs under Wine automatically. The automated platform tier is gdi-windows-ci.yml, a native MSVC job on a hosted runner that runs ctest -L GDI, again on manual dispatch only. Its own header states that it does not replace an inspection of the visible window lifecycle and DPI behaviour on an interactive desktop.

What a Windows 2D result has to say

A clean device construction proves neither pixel output nor the release gate. A useful report names the identity, whether it ran on native Windows or on Wine or Proton built-ins (and which), whether the observation was presentation, back-buffer readback or off-screen target readback, which skip variables were active, and for Direct2D whether the hardware driver or WARP was used (CNA_DIRECT2D_DIAGNOSTICS prints it). The evidence vocabulary is the one in How renderers are verified.

Choosing and testing the Windows 2D paths

Choose DIRECT2D when native Direct2D resources and composition are the goal, and accept its narrower blend vocabulary (no additive preset) and its hardware-or-WARP device. Choose GDI when a deterministic CPU-rendered image delivered through the classic Win32 surface is valuable, including stencil masks, wireframe sprites and opt-in 4× MSAA. Neither is a route to programmable 3D: for that on Windows use DIRECTX11, DIRECTX12 or DIRECTX9 (Tutorial 103).

A comparison scene that separates the two stacks includes alpha-blended sprites (and additive ones on GDI, where they are supported), a clipped draw, a render-target round trip and a format-sensitive readback; on GDI add stencil and MSAA probes for the shared CPU core. Run the same scene off-screen and through real presentation, so that the raster boundary and the delivery boundary are tested separately.

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