DIRECTX11 and DIRECTX12 internals: lifetimes, frames in flight, descriptors and target finalisation

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 renderer sources and fixture registrations at 009d40f5; nothing was executed. A genuine DXGI device removal is not triggered by any CNA test; recovery is exercised through the deterministic simulation path.

DIRECTX11 and DIRECTX12 share shaders, format and state tables through D3DCommon, but not resource algorithms: Direct3D 11 lets the driver own command lists and transitions, Direct3D 12 makes CNA own them. This page describes the resource side of both renderers at this snapshot: device creation and the three lifetime groups, device recovery, Direct3D 12 frames in flight, the growable and fence-safe descriptor heaps, the windowless HeadlessEXT device, back-buffer readback, how MRT sets and cube targets are finalised, and how render-target usage is honoured. It corrects several statements that earlier descriptions of these renderers made. It is for maintainers of either renderer and for anyone debugging lifetime, stale-texture or descriptor-exhaustion reports on Windows.

What the two renderers share

D3DCommon holds one Shader Model 5 DXBC package of stock shaders (hlsl_shaders.hpp, compiled offline; see how it is built), the SurfaceFormat-to-DXGI_FORMAT and XNA-state mappings, constant-buffer layouts, vertex-declaration helpers, the presentation geometry and the program reflection used by ShaderEffect. Both renderers classify formats by asking the device: the 20 classic XNA formats with native BC1–BC3, and the CNA extension formats classified unsupported (texture formats). DIRECTX9 uses none of this; its D3DFORMAT world is separate.

Shared tables do not mean shared algorithms. The sections below repeatedly show the same public behaviour reached by different native routes, which is why a Direct3D 11 pass is not evidence for Direct3D 12.

At this snapshot the DIRECTX12 family implements the device, a single command queue, four descriptor allocators, two frame slots with one shared fence, per-resource barrier tracking (D3D12ResourceStateTracker), root-signature and pipeline-state caches, a per-slot sampler system, buffers, 2D, cube and 3D textures, render targets with MRT, device-queried MSAA and mip generation, occlusion queries, the stock shader variants, SpriteBatch, run-time ShaderEffect HLSL and device-removed recovery; the sections below show where each part differs from the Direct3D 11 route.

DIRECTX11 device creation and the three lifetime groups

DirectX11Renderer::CreateDeviceResources (DirectX11Renderer.cpp) negotiates feature levels 11_1, 11_0, 10_1 and 10_0 on a hardware driver, retries without 11_1 when a driver rejects the explicit request with E_INVALIDARG, and then refuses anything below 11_0, because the SM5 stock shaders need it. It probes whether the device really keeps the bytes it is given for three packed formats (B5G6R5, B5G5R5A1, B4G4R4A4); a device that reports B4G4R4A4 but does not store its texels makes SurfaceFormat::Bgra4444 unavailable with a warning rather than silently corrupt. Window resize and device loss affect different resources, so the renderer keeps three lifetime groups:

GroupObjectsRecreated on
DeviceID3D11Device, immediate context, the IDXGIFactory2 chain, the cached tearing-support flagdevice recovery only
Swap chainthe IDXGISwapChain1 (flip-discard, two buffers, never multisampled)device recovery only; a resize reuses the object with ResizeBuffers()
Window sizeback-buffer view, the optional multisampled colour texture, the depth texture and view, the viewportevery resize (released before ResizeBuffers(), recreated after) and every recovery

This division avoids both recreating the device on resize and forgetting the swap chain during recovery. Back-buffer MSAA is a separate multisampled texture resolved into the swap-chain buffer before each Present() and before readback. Every Direct3D 11, DXGI and Direct3D 12 interface is held in Microsoft::WRL::ComPtr, including under MinGW. DIRECTX11 asks only for a hardware driver and has no WARP path; DIRECTX12 can be pointed at WARP with CNA_D3D12_ADAPTER=warp.

Device recovery: real code, simulated trigger

Both renderers route DXGI_ERROR_DEVICE_REMOVED and DXGI_ERROR_DEVICE_RESET from Present(), ResizeBuffers() and fence signalling into a lost state, log GetDeviceRemovedReason() and raise DeviceLost. Recovery then rebuilds everything: RecreateDeviceEXT releases every registered long-lived buffer, texture and render target, drops all caches and stock shaders, recreates device, swap chain and window-size resources (Direct3D 12 also its queue, heaps, allocators and fence, under the configuration captured at first creation) and asks each registered resource to recreate itself, reporting how many failed. Registration follows SetContextRecoveryEnabled. On Direct3D 12 the renderer's own lazily created fallback textures are dropped before the registry is copied: they used to be destroyed after the copy, so every recovery after a PBR draw called into freed memory (DX12-0017).

A genuine device removal cannot be triggered on the development loop. The recovery path is exercised deterministically through DebugSimulateContextLoss() and DebugRestoreContext() (the F9/F10 keys and the shared d3d_context_recovery_contract_test.cpp, DX-244), which raise Lost, Resetting and Reset around a real recreation. Proving that a real removal reaches this path is the native-hardware item CNA keeps open.

DIRECTX12: two frames in flight

Direct3D 12 makes command-allocator lifetime explicit, so DirectX12Renderer (DirectX12Renderer.hpp) keeps kFramesInFlight = 2 frame slots. Each slot owns a command allocator, a graphics command list, a 256-byte-aligned constant arena (grown in 1 MiB chunks), a persistently mapped upload ring and a list of retained COM objects; one shared fence and one remembered fence value per slot guard them. A windowed device uses the swap chain's current back-buffer index as the slot; a windowless one rotates the two slots at each Present().

  • Recording. Clears, draws, resolves, SpriteBatch flushes and per-draw constants all record into the open frame list (DX-237). Every resource a command references is retained in the slot until that slot's fence completes, so destroying a texture right after drawing it is safe.
  • Submission. SubmitFrameCommandsEXT() closes, executes and signals the slot's reserved fence value without waiting.
  • Back-pressure. GetFrameCommandListEXT() waits only when the slot it is about to reuse still has an incomplete fence value from two frames ago, then resets that slot's allocator, list, arena and ring.
  • Synchronous islands. CPU readbacks, uploads that must complete, and native test helpers use a separate immediate list through ExecuteCommandListAndWaitEXT(), which first submits the open frame, then signals a fresh value and waits for exactly that value.

The older primitive SignalAndWaitForFrameEXT(frameIndex) keeps the rule every frame-slot scheme depends on and is worth understanding for that reason. It signals a new, monotonically increasing value for the frame just submitted and waits only for the previous value recorded for the same slot. A call for slot 0 may therefore signal v2 while waiting for v0; waiting for v2 would erase the overlap two slots exist to permit, and asserting that v2 completed on return would be a race, because ID3D12CommandQueue::Signal() is asynchronous. DirectX12_Smoke calls it for slots 0, 1 and 0 and asserts only that the values increase and that reusing slot 0 waited for v0. The shared Buffer_DynamicStress fixture adds the steady-state claims for Direct3D 12: after warm-up the upload ring creates no new resources, a measured frame performs at most one actual GPU wait, and vertex and index updates receive distinct ring ranges. Earlier descriptions said that ordinary drawing waited for the GPU after every clear and draw; that is no longer the code.

DIRECTX12 descriptor heaps: they grow and recycle

Render-target, depth-stencil, shader-resource and sampler descriptors come from four allocators in D3D12DescriptorHeaps.hpp. The first implementation (DX-103) gave each heap a fixed capacity and a monotonic bump cursor with no free list: every slot ever handed out was consumed for the life of the process, so the 65th sampleable resource ever created threw even when only six were alive, and the capacity constants had been raised several times to keep a growing test suite passing. REMED-GFX-177 replaced that with reclamation and growth; the old capacities (64 RTV, 8 DSV, 64 CBV/SRV/UAV, 16 sampler) are now only the starting sizes.

  • RTV and DSV: a chain of blocks. Render-target and depth-stencil views are never bound as a heap; OMSetRenderTargets takes loose CPU handles. D3D12CpuDescriptorAllocator therefore grows by appending another heap block, doubling in size, so every issued handle stays valid forever and nothing that stored one has to be told about growth.
  • CBV/SRV/UAV and sampler: a mirrored, replaceable heap. A shader-visible heap is bound whole and a descriptor table may point only into the bound heap, so it cannot be a chain; it must be replaced. Direct3D 12 also forbids copying descriptors out of a shader-visible heap. D3D12ShaderVisibleDescriptorAllocator therefore keeps the authoritative copy of every descriptor in a non-shader-visible staging heap and mirrors each write into the shader-visible one (Publish); growth creates a larger pair and re-mirrors the live range from the staging heap, a legal copy source. Callers hold a stable index, never a GPU handle, and resolve the handle at the point of use, because growth replaces the heap object. Capacity doubles up to the specification's ceilings, 1,000,000 CBV/SRV/UAV descriptors (Tier 1) and 2,048 samplers, and live demand beyond that throws a named std::runtime_error.
  • Fence safety. A freed index, and a replaced heap, are stamped with the latest fence value that could still reference them (the last submission or the open frame's reserved value) and recycled or released only after the shared fence passes it. Nothing waits, idles the device or allocates a heap per draw; growth happens only when simultaneous live demand exceeds the free list.
  • Ownership. The allocator set is held through a std::shared_ptr captured by every descriptor-owning resource, so a resource destroyed after its renderer still frees into a live allocator. Samplers are allocated only on a sampler-cache miss, one per distinct XNA SamplerState combination, so repeated identical bindings consume nothing.

CNA_D3D12_DESCRIPTOR_TRACE prints the real counters (D3D12DescriptorHeapStats: capacity, live, peak, never-used, free-ready and free-pending slots, recycles, growths, heap objects, generation, publishes and bulk copies). The D3D12-only fixture directx12_descriptor_allocator_test.cpp (DirectX12_DescriptorAllocator, registered without the forced-headless mode) checks the claims a pixel cannot show: a freed slot is reissued (as a recycle count and a repeated index), 200 create/destroy cycles neither grow the heap nor advance the never-used cursor beyond one generation, growth under 300 simultaneous resources reaches exactly 512 with a generation bump and moved handles but stable indices, the retired heap survives until its fence passes, and repeated identical sampler bindings allocate nothing. The public-API half, a high-cardinality workload rendering correctly on every renderer, is descriptor_capacity_contract_test.cpp.

HeadlessEXT: a windowless DIRECTX12 device

SpriteFont, Model and parts of Texture2D.FromStream/SaveAsPng need a GraphicsDevice, and on the Linux loop a Direct3D 12 window needs the heavy Proton route. PresentationParameters therefore has the CNA-only HeadlessEXT property (getHeadlessEXTProperty()/setHeadlessEXTProperty(), default false), and CNA_FORCE_HEADLESS_DEVICE_EXT applies it to named renderers from the environment. GraphicsDevice then creates no window and does not initialise the video subsystem. Two renderers honour it, DIRECTX12 and SDL_GPU; DIRECTX11 creates its swap chain in its constructor and EasyGL's context is bound to a window, so both throw from their constructors, which the property's documentation calls the honest outcome.

A windowless DIRECTX12 device is not target-less. CreateOffscreenBackBufferResources (DX-241) creates an implicit off-screen R8G8B8A8_UNORM back buffer with the same depth attachment a windowed device gets, sized from the construction-time back-buffer size, and binds it as the default target; SetRenderTarget(nullptr) returns to it. The same resource is used when a swap chain was requested but could not be created. Present() on such a device resolves back-buffer MSAA, submits the frame and rotates the frame slot without presenting, and GetBackBufferData() reads what the frame drew. Until a caller explicitly selects a presentation mode after setting a virtual resolution, a windowless device keeps plain local-pixel sizing.

Back-buffer readback

Both renderers override ReadBackbuffer. DIRECTX11 resolves MSAA, copies the back buffer into a staging texture and honours RowPitch; DIRECTX12 copies through a readback heap on the immediate list, returning the source to its tracked state so a readback never changes what the next draw or present must transition from. Both map a logical request through the presentation geometry (readback mapping) and throw NotSupportedException, by name, when there is no back buffer instead of returning a zero-filled frame as a success. Earlier descriptions said DIRECTX12 had no back-buffer readback and that its evidence came only from render-target helpers; since DX-205 and DX-241 the public GetBackBufferData() route works windowed and windowless.

Finalising MRT sets

A single RenderTarget2D has a simple end-of-use rule: when it is unbound, ResolveAndGenerateMipsEXT() resolves the multisampled draw texture into the separate sampleable texture and, for a mipmapped target, regenerates the chain, so a later sampler reads the current image. The rule was once silently missing for an MRT set, because the single-target pointer could not represent several targets: the old code bound all views with OMSetRenderTargets() and finalised none when the set was replaced, and a game could draw into two MSAA targets and then sample stale single-sample textures without any error. Both renderers now keep a non-owning array and count for the active set. On DIRECTX11, SetRenderTarget2D() and SetRenderTargets() first call FlushPendingMRTResolveEXT() (DX-143), which finalises every stored target and clears its entry before anything is bound; DIRECTX12 has the same structure in FlushPendingMrtResolveEXT() (DX-255), which it had lacked. All three transitions are covered: MRT to back buffer, MRT to a single target, and MRT to another MRT set. DIRECTX12 binds the complete ordered set to every draw (DX-224) and folds all four XNA colour-write masks into its pipeline-state key; CNA's stock shaders write only the first target.

The proof has to read the sampleable textures of every target, because a repair that resolved only target zero, or that merely left the MSAA draw texture valid, would pass a weaker check. The shared MRT fixture (easygl_mrt_test.cpp, REMED-GFX-016/DX-224), registered for both renderers, does this with equivalent GLSL and HLSL ShaderEffect programs that write distinct outputs to targets 0 to 3: it covers ordered one-to-four-target rendering, independent colour-write masks, first-target depth ownership, true MRT MSAA with resolve, target-set replacement and producer-to-consumer sampling right after unbind. RenderTarget_MsaaFirstReadback and RenderTarget_ActiveMsaaReadback add the single-target rule that stale resolve data is never a valid answer. That MSAA MRT sets and mipmapped targets are finalised by the same helper is wired in source, and a fixture combines MRT with mipMap = true: leg L1 of bound_target_lifetime_test.cpp (Resource_BoundTargetLifetime, registered for DIRECTX11 and DIRECTX12) destroys one slot of a bound multi-target set and requires the surviving target's mip chain to be regenerated.

Cube targets: MSAA, mip chains and the public finalisation edge

Neither Direct3D 11 nor Direct3D 12 allows a multisampled cube resource or cube view. On both renderers the MSAA colour resource of a RenderTargetCube is therefore a plain six-slice Texture2DMSArray used only as a render-target view, and a second, single-sample, cube-flagged resource receives ResolveSubresource() for the active face on unbind; the shader-resource view targets the resolved resource. Direct3D 12 additionally transitions the two resources to RESOLVE_SOURCE and RESOLVE_DEST, barriers Direct3D 11's context performs implicitly. Both resolve only the currently active face, and GetMultiSampleCount() reports the applied count.

Mip generation is where the algorithms really part:

  • DIRECTX11 calls ID3D11DeviceContext::GenerateMips() on the shader-resource view; for a cube that view spans all six faces, so every face's chain is regenerated on the GPU (unchanged faces reproduce their previous levels).
  • DIRECTX12 has no such call. GenerateMipsEXT() reads the previous level back with a synchronous readback, box-filters it on the CPU and uploads the next level, level by level; for a cube only the active face. The result is the same chain; synchronisation and cost are very different.

An interface-default audit found a routing hole after these algorithms landed: early native smoke checks called BindAsRenderTargetFace() and UnbindAsRenderTarget() directly, while the public route never tracked a bound cube, so a multisampled cube's resolve texture stayed empty and a mipmapped cube's upper levels were never regenerated. Both renderers now override the cube-face route, keep the active cube separately from single-target and MRT state, and call FlushPendingCubeResolveEXT() before any cube-to-cube, cube-to-2D, cube-to-MRT or cube-to-back-buffer transition (REMED-GFX-134): the outgoing cube's resolve and mip generation run before the new destination replaces native state. A single cube face in the plural binding path is delegated to the same route; cube faces inside a multi-target set remain an explicit refusal. Shared fixtures (RenderTargetCube_SampleAfterUnbind, RenderTargetCube_Usage, RenderTargetCube_MsaaFace, RenderTargetCube_GetDataContract) drive an asymmetric cube face through public binding and readback, so a skipped finalisation, wrong face or stale resolve fails visibly.

Render-target usage on persistent resources

Direct3D 11 and 12 bind render targets without a load action, so a resource keeps its contents across binds. Both factories accept the preserveContents flag and deliberately leave it unused; the visible policy lives in GraphicsDevice::SetRenderTargets, which, when the first bound target's usage is DiscardContents, clears it to opaque black plus whichever depth and stencil planes that attachment really has. PreserveContents and PlatformContents clear nothing, and the first descriptor decides for an MRT set. Rebinding an identical target set does nothing: GraphicsDevice::SetRenderTargets returns early when every target and cube face matches the current binding, as XNA does (rebinding is a no-op), before any resolve, discard clear or viewport reset. A genuine change of binding, including a different cube face, finalises the outgoing binding first and clears a DiscardContents target again. The shared RenderTarget_Usage, RenderTarget_DepthStencilUsage, RenderTargetCube_Usage and GraphicsDevice_OrderedClear fixtures, which render an asymmetric image and rebind with only a small marker so a discard masquerading as preservation cannot pass, are registered for DIRECTX11 and DIRECTX12 alike; DIRECTX9 registers the cube-usage, depth/stencil-usage and ordered-clear fixtures in its own list (DirectX9_RenderTargetCube_Usage, DirectX9_RenderTarget_DepthStencilUsage, DirectX9_GraphicsDevice_OrderedClear) but not the 2D RenderTarget_Usage fixture. The cross-renderer rules are on RenderTargetUsage.

Worked case: four defects that only the public device route could find

The first routine public-GraphicsDevice fixtures on DIRECTX12 exposed four defects that direct renderer smoke tests had not traversed. They are recorded here as a historical case; all four are absent at this snapshot:

  • SpriteBatch ignored sampler filter and address-mode updates and inherited state from a prior 3D draw. The SpriteBatch flush now resolves slot 0's descriptor from its own pending sampler state through the same dynamic sampler system as the 3D draws.
  • Render targets failed to bind their depth-stencil views. A depth-bearing target now allocates its own DSV and binds it with its colour view.
  • The six combined colour, depth and stencil clear variants threw. They now share ClearImpl.
  • SetDepthTestEnabled, SetDepthWriteEnabled and SetBlendEnabled threw when the Model route called them. The first two now feed the pipeline-state key; the third is a deliberate no-op on every Direct3D renderer, because a bare "enable blending" has no blend factors and ApplyBlendState always carries the real configuration.

The lesson generalises: direct smoke tests and public-API fixtures are complementary. The first prove native mechanisms in isolation, the second prove the shared framework's call paths actually reach them.

Statements that no longer hold

Several claims made about these renderers in earlier descriptions are false at this snapshot, and are listed so that they are not reintroduced:

Earlier statementAt this snapshot
Direct3D 12 descriptor heaps are fixed bump allocators that never reuse a slotthey reclaim and grow (REMED-GFX-177); the old sizes are starting sizes
Ordinary Direct3D 12 drawing submits and waits synchronously; two frames in flight is only tested infrastructuredraws record into per-slot frame lists and submit without waiting (DX-237); readbacks stay synchronous
Direct3D 12 has no back-buffer readback; a windowless device has no default targetboth exist (DX-205, DX-241)
Direct3D 12 has no scissor hookscissor is recorded per draw (DX-201)
The Direct3D 11 and 12 swap chains ignore the requested depth formatthe default depth attachment follows it
Only Direct3D 12 honours HeadlessEXTSDL_GPU honours it too

Read in this order

  1. DirectX12Renderer.hpp: the frame-slot, heap and binding members (some older member comments describe states that later tasks changed; the .cpp is authoritative).
  2. D3D12DescriptorHeaps.cpp: the two allocators.
  3. DirectX12Renderer.cpp: GetFrameCommandListEXT, SubmitFrameCommandsEXT, Present, RecreateDeviceEXT.
  4. DirectX11Renderer.cpp: device creation, EnsureSwapChainSize, the flush helpers.
  5. D3D11RenderTargets.cpp and D3D12RenderTargets.cpp: resolve and mip generation side by side.

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