The renderer contract: IGraphicsRenderer defaults, factories and failure shapes

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 contract header, the public wrappers and each family's sources at 009d40f5; per-family override lists come from a source search and show that a family made a decision, not that it is correct. Nothing was built or executed.

Every one of CNA's 21 renderer families implements one internal C++ contract, IGraphicsRenderer and its resource interfaces, and that contract is deliberately permissive: some methods must be written, others fall back to a null object, a silent no-op, a reduced operation or an exception. Reaching an interface method therefore says nothing about whether the selected family did the requested work. This page maps the contract as it stands at snapshot 009d40f5 — which bodies are required, which defaults remain and what each default does to a public call — and states the evidence sequence that turns a capability answer into a proven feature. It is for renderer implementers, for anyone triaging a "it rendered, but wrongly" report, and for game authors who branch on capability answers.

One header, seventeen interfaces

The whole contract lives in IGraphicsRenderer.hpp (compiled XNA effects add ICompiledEffectRuntime from its own header). Public XNA objects hold one renderer handle each; the renderer object answers device-wide operations. At this snapshot the header declares seventeen interface classes — more than the classic set, because the CNAEXT modern-graphics surface (storage buffers, compute, texture arrays, storage images, GPU timers) was added through the same contract.

InterfaceResponsibilityCreated by
IVertexBufferRenderer, IIndexBufferRendererVertex upload with the complete declaration; 16- and 32-bit index upload; optional SetDataOptions hintsCreateVertexBuffer, CreateIndexBuffer16 (required), CreateIndexBuffer32 (throws unless overridden)
ITextureRenderer2D texture storage, level-zero and higher-level uploads, readback, CPU-shadow sharingCreateTexture(ImageData) (required)
ITexture3DRenderer, ITextureCubeRendererVolume and cube storage with completion-reporting transfersCreateTexture3D, CreateTextureCube (null by default)
IRenderTargetRenderer, IRenderTargetCubeRendererBinding, applied sample count and depth format, real depth and stencil presenceCreateRenderTarget2D/…EXT, CreateRenderTargetCube/…EXT (null by default)
IEffectRendererProgram compile, bind and status; uniforms and texture bindings for ShaderEffectCreateEffectRenderer (null by default)
IOcclusionQueryRendererBegin, end, completion, sample count, and whether the count is exactCreateOcclusionQuery (null by default)
ISpriteBatchRendererBatch begin/end, transform, sampler options and the sprite draw formsCreateSpriteBatch (required)
IGpuTimerRenderer, IStorageBufferRenderer, IComputeShaderRenderer, ITexture2DArrayRenderer, IStorageTexture2DRendererThe CNAEXT modern surface used by the engine layerMatching Create… factories, all null by default
IRendererThreadContextLeaseA token that owns a GL context for one bounded operationAcquireThreadContextLeaseEXT (null by default; only EasyGL and OpenGL4 return one)
IGraphicsRendererPresentation, factories, state, targets, clears, draws, readback, capability answers, debug hooksThe family's own descriptor factory (see below)

The family map with every family's directory, descriptor and build gate is Graphics backends; the obligations every family shares (format classification, context affinity, logical versus drawable size, honest refusal) are listed in its common obligations section. This page is about the defaults a family inherits when it does not say otherwise.

Five kinds of default body

The default bodies fall into five kinds, and each produces a different public symptom when a family leaves it alone.

KindRepresentative members at this snapshotWhat a caller sees if the family inherits it
Pure virtualCreateTexture, CreateSpriteBatch, CreateVertexBuffer, CreateIndexBuffer16, SetRenderTargets, the seven clear entry points, DrawColoredPrimitives, SetVirtualResolution, SetPresentationMode, IVertexBufferRenderer::SetVertexDeclaration, IEffectRenderer::CompileProgram/Bind/Unbind/IsValid, the three classic sprite draw formsNothing: the family does not compile until it makes an explicit decision. SetVertexDeclaration and SetRenderTargets were made pure on purpose, so a new family cannot silently discard a declaration or flatten a cube face.
Null factoryCreateTexture3D, CreateTextureCube, CreateRenderTarget2D, CreateRenderTargetCube, CreateEffectRenderer, CreateOcclusionQuery, CreateCompiledEffect and every modern-surface factoryDepends on the public wrapper: some refuse at construction, others construct an object with no storage and refuse at first use (next section).
Silent no-opApplyBlendState, ApplyDepthStencilState, ApplyRasterizerState, ApplySamplerState and its mip and W-address variants, SetBlendFactor, SetReferenceStencil, SetScissorRect, SetViewport, SetSwapInterval, SetContextRecoveryEnabled, SetStringMarkerEXT, DebugSimulateContextLoss/DebugRestoreContext, DispatchCompute, the effect uniform and texture setters, the sprite sampler settersThe call returns normally and the public property stores the value, with no native effect.
Reduced fallbackDrawPrimitivesEx/DrawIndexedPrimitivesEx call the coloured draw and drop GpuDrawParams; SetDataWithOptions drops the SetDataOptions hint; CreateRenderTarget2DEXT/CreateRenderTargetCubeEXT drop the requested SurfaceFormat; the sub-pixel sprite Draw(texture, float x, float y, float w, float h, …) truncates the destination to whole pixelsWork completes, with part of the request discarded — a plausible but wrong picture rather than an error.
Throw or refusalReadBackbuffer, DrawInstancedPrimitivesEx, both indirect draws, CreateIndexBuffer32, SetData32; texture GetData and cube/volume transfers return falseAn explicit failure. The exception type still varies: the header defaults throw std::runtime_error, while the public wrappers turn a false transfer into System::NotSupportedException.
ℹ

Treat every optional method as its own capability. A non-null renderer object, a true capability answer and one successful draw are three different facts, and none implies the next. The evidence ladder below is the sequence that establishes a feature.

Which reduced fallbacks are still reachable is a family question. By a search of the family sources at this snapshot, every 3D-capable family overrides both effect-aware draws, so the coloured fallback is reached only by STUB (whose coloured draw is itself a no-op); the 2D-only families refuse earlier through Ensure3DSupported or their own coloured hooks. The sub-pixel sprite form is overridden only by EasyGL, OpenGL4 and Software, so fractional sprite destinations are quantised on every other family. SetDataWithOptions is overridden by DirectX 9, DirectX 11, DirectX 12, EasyGL, FNA3D, Headless, OpenGL4, SDL_GPU, Software and WebGPU, but an override does not mean the hint is honoured. Only DirectX 9 (D3DLOCK_DISCARD versus D3DLOCK_NOOVERWRITE), DirectX 11 (D3D11_MAP_WRITE_DISCARD versus D3D11_MAP_WRITE_NO_OVERWRITE), EasyGL and OpenGL4 (orphan the buffer store versus write into it), SDL_GPU (cycle the buffer unless the option is NoOverwrite) and FNA3D (its own set-data option, degraded to None where the driver cannot guarantee no-overwrite) turn Discard and NoOverwrite into different native operations. Vulkan inherits the option-dropping default, and the WebGPU, DirectX 12, Software and Headless overrides treat all three options identically (WebGPU because every draw snapshots its bytes, DirectX 12 because its per-frame upload ring already covers both, Software and Headless by forwarding to plain SetData).

Null factories and where they fail

The null default is not one failure policy. Each public wrapper decides what a missing renderer object means, and at this snapshot the answers are:

Public typeConstruction when the factory returns nullFirst use
OcclusionQueryRefused: NotSupportedException under the Reach profile, when GraphicsCapability::OcclusionQuery is false, or when the capability is true but the factory still returned nullNot reachable; there is no inert query object any more
Texture3DRefused up front when GraphicsCapability::Texture3D is false; if the capability is true and the factory still returns null, construction succeedsSetData/GetData throw NotSupportedException (formerly a silent no-op)
TextureCubeSucceeds (no cube capability exists to ask)SetData/GetData throw NotSupportedException ("this renderer creates no cube-map resource")
RenderTarget2D, RenderTargetCubeSucceeds by design, after format and profile validationBinding throws NotSupportedException inside SetRenderTargets before any public binding state changes; data transfers refuse likewise

The RenderTarget2D source states the reasoning: a target may never be bound or sampled, so construction is allowed and every operation that needs storage checks for it. Before this was unified, the singular SetRenderTarget(rt) path passed the renderer's null handle through as an ordinary "restore the back buffer" request while the device recorded the target as bound, so every later draw silently landed in the back buffer; it now funnels through SetRenderTargets and its refusal. OcclusionQuery.cpp, Texture3D.cpp, TextureCube.cpp and RenderTarget2D.cpp own these rules.

By the same source search, PORTABLEGL and STUB keep the null render-target and occlusion-query defaults, and a family may also override a factory only to refuse (the 2D-only SDL_RENDERER, for example, answers CreateOcclusionQuery through its unsupported-3D policy). Which families have real render-target storage is tabulated in Render targets: renderer implementation.

⚠

One sharp edge remains by reading: ShaderEffect::SetTexture(int, TextureCube&) and its Texture3D overload pass texture.GetRenderer(), and those accessors dereference the renderer handle unconditionally. A cube or volume texture whose factory returned null therefore cannot be handed to a ShaderEffect safely. No capability value covers every texture and target factory, so portable code checks the specific public operation, not one Boolean. The case is recorded for Known Issues review, not proven by a test.

Resource ownership is by address, not by handle

A public GraphicsResource owns its renderer handle — a unique_ptr for buffers, queries and most resources, a shared_ptr for Texture2D, so value copies of a texture share one renderer object. The GraphicsDevice owns neither: it keeps raw GraphicsResource* addresses so that Dispose can dispose every registered resource while the renderer still exists, and each resource keeps a weak lifetime token so that a resource destroyed after its device never dereferences it. GraphicsResource.cpp and GraphicsDevice.cpp (Dispose) are the source.

Two statements that older descriptions made are not true at this snapshot. The device marks itself disposed and raises Disposing before it moves and clears the registry and disposes each resource, and only then destroys the renderer, presenter and window; and a move of a buffer, texture or render target now transfers the registry entry (TransferResourceReference, and for textures TransferMovedTexture), marking the moved-from object disposed. What remains true is the copy side: a Texture2D copy is not registered, has its own disposed flag and shares the renderer object, so a copy that outlives the device can release its renderer object after the family has gone. Vulkan disconnects such survivors in its destructor; other families rely on the rule "dispose every alias before the device". The full disposal order and the registry reasoning are on GraphicsDevice internals and Textures and render targets.

Buffers: the CPU shadow is the readback

VertexBuffer::GetData and IndexBuffer::GetData never read GPU memory on any family. Every upload path — typed SetData, the SetDataOptions overloads of the dynamic buffers and the CNAEXT SetDataRaw — goes through one routine (VertexBuffer::UploadValidatedData, and its index-buffer twin) that first writes the bytes into the resource's own cpuShadow_ and then submits the whole logical buffer to the renderer. XNA 4.0 has no GPU write path into these buffers (no transform feedback, no compute), so the shadow preserves the observable SetData-then-GetData contract exactly.

  • Whole-buffer submission is a deliberate cost trade. The renderer contract has no destination offset, so a prefix update or the windowed SetDataRawAtEXT is applied to the shadow and the complete shadow is uploaded; bytes outside the window keep their contents, bytes never written read as zero, and Discard zero-fills the shadow first.
  • The options overloads refresh the shadow. An earlier state in which the dynamic buffers' options-taking SetData uploaded without refreshing the shadow — so a later GetData saw stale or empty bytes on every family — does not exist at this snapshot; the shared upload routine is the only path.
  • Custom layouts can be read back. VertexBuffer::GetDataRawEXT(offsetInBytes, destination, count, stride) is the counterpart of SetDataRaw, added precisely because a buffer filled with a custom declaration could previously be written and never read.
  • Write-only buffers still keep a shadow. BufferUsage::WriteOnly makes every GetData throw NotSupportedException, matching XNA; the shadow itself is still maintained because whole-buffer submission needs it.

Dynamic buffers use the same renderer factory as static ones: CreateVertexBuffer(capacity) has no dynamic parameter, so the renderer learns a buffer's streaming intent only from the SetDataOptions passed with each upload. How the families map those options is summarised above. IVertexBufferRenderer::SetVertexDeclaration is called immediately before every upload with the complete declaration, and it is pure virtual so that each family explicitly translates, stores for draw-time validation, or rejects it; the element-offset and multi-stream rules the device applies before a draw are traced on Indexed draw trace.

Effect-aware draws and instancing

DrawPrimitivesEx and DrawIndexedPrimitivesEx receive the complete draw description in GpuDrawParams: the applied effect's parameters, the vertex-stream table, a compiled-effect runtime or a custom-shader request. Their default forwards to the coloured draw and throws that description away, which is the most consequential reduced fallback in the contract: an effect request can come back as an untextured coloured result. At this snapshot the fallback is unreachable on every 3D-capable family (see above), and the families that select a stock program do not silently draw something else, though their refusals are narrower than “cannot feed it”: OpenGL4 (RequireDeclarationFitsStockProgram) and EasyGL (the shared RequireDeclarationMatchesStockProgram) refuse only a consumed element whose format they cannot convert, while an input the declaration omits stays unbound and reads the GL default (0, 0, 0, 1); Vulkan refuses an incomplete multi-stream declaration and sends an incomplete single-stream one to its stride-inferred route behind RequireFaithfulDeclarationEXT, which refuses what that route would read from the wrong bytes. A test of a custom vertex layout must still check pixels or bindings, not only that the call returned.

The bare coloured draws are a different contract from the effect-aware ones. DrawColoredPrimitives and DrawIndexedColoredPrimitives take only the buffers, the world, view and projection matrices, the primitive type and the count; the header describes the renderer as applying a basic coloured-vertex shader, equivalent to BasicEffect with VertexColorEnabled = true, and they carry no GpuDrawParams by design. A family whose coloured callback ignores an applied effect is therefore not, by that alone, discarding a payload it was given; the question for such a family is whether its Draw*PrimitivesEx route, which does receive the payload, reached the native stock or custom program.

Instancing has a throwing default. DrawInstancedPrimitivesEx throws unless the family overrides it (a 2D-only family under WarnAndStub gets a warning and a no-op instead). It receives the same complete stream description as the ordinary routes, captured by value: each binding carries its slot, renderer buffer, declaration, stride, element offset and instance frequency, per-instance streams are those with a frequency above zero, and a deferred family must copy what it needs rather than re-read the device's public bindings at replay. GraphicsDevice accepts at most sixteen bindings, derives per-element usage indices, validates ranges only for streams the stock program reads, and rejects more than one stream of the same input rate unless the renderer reports GraphicsCapability::MultiStreamVertexInput — so the broad portable subset is one per-vertex plus one per-instance stream. By the source search, the instanced route is implemented by DirectX 9, DirectX 11, DirectX 12, EasyGL (not on the ES 2.0 generation, which reports Instancing false), FNA3D, OpenGL4, SDL_GPU, Software, Vulkan and WebGPU; GDI overrides it only to refuse, Metal, PortableGL and the other 2D-only families keep the throwing default, and Headless records the call without rasterising, so it proves routing, not instancing.

When the declaration lacks an input the stock effect reads

Microsoft XNA 4.0 refuses such a draw. GraphicsDevice.VerifyCanDraw compares the bound VertexDeclaration with the vertex shader's input signature and throws InvalidOperationException (resource MissingVertexShaderInput, read from the IL of the genuine XNA 4.0 graphics assembly). CNA has no shared counterpart of that check at this snapshot. StockEffectUsesVertexSemantic in IGraphicsRenderer.hpp encodes which semantics each stock variant reads, but the shared layer uses it only to decide which streams it range-checks (validation order). Each family decides the rest; five were read for this question:

FamilyA semantic the effect reads is not declared
EasyGL (five GL identities), OPENGL4The program is chosen from effect state alone (SelectStockProgramShape), and the missing attribute stays disabled, so it reads OpenGL's default (0, 0, 0, 1). RequireDeclarationMatchesStockProgram refuses only an element whose format cannot be converted
SDL_GPUThe shape comes from the effect family plus the declared semantics (SelectStockVertexShapeEXT). For BasicEffect, texturing without TEXCOORD0 or lighting without a normal is silently dropped. Alpha test (compare function not Always), dual texture, environment mapping and PBR without their defining inputs throw NotSupportedException; skinning throws std::invalid_argument. A secondary input such as TEXCOORD1 or COLOR0 reads the (0, 0, 0, 1) neutral record
WEBGPUAs SDL_GPU, with three exceptions. Environment mapping without its inputs, and a non-skinned PbrEffect on a non-PBR stride, fall through to a BasicEffect shape. Alpha test or dual texture without TEXCOORD0 ends in the renderer's own DrawColoredPrimitives, which draws a single-stream, stride-16 position-and-colour buffer untextured and refuses any other layout
VULKANAn incomplete declared layout makes a multi-stream draw throw NotSupportedException. A single-stream draw returns to the stride-derived route behind RequireFaithfulDeclarationEXT. For DualTextureEffect, the renderer points TEXCOORD1 at the declared TEXCOORD0 (VULKAN-150)

The neutral record is deliberate. StockVertexSemantics.hpp documents it as the value D3D9 fills into a register's missing components and the value the reference renderer's disabled attribute holds. It is still more permissive than XNA, and the families disagree with one another. Take a DualTextureEffect draw on a one-UV mesh, which XNA refuses. Vulkan samples the second texture with the first UV set, and the other four families sample it at (0, 0). Take a textured BasicEffect on a position-and-colour mesh. EasyGL and OpenGL4 sample the texel at (0, 0), and SDL_GPU and WebGPU draw untextured. CNA's own rule for defaults ("inheriting 'throws' is fine, only inheriting silence is the trap") names the risk. Supply every semantic the effect variant reads, and test such draws by pixels. The divergence is recorded for Known Issues review; the other families were not re-read.

An older static reading called the SDL_GPU, WebGPU and OpenGL4 routes "hybrid", with stride-keyed coloured tails. At this snapshot OpenGL4 has no tail, SDL_GPU refuses the effect families above, and only WebGPU keeps a coloured tail, in the two cases in its row. SDL_GPU also routes a valid custom ShaderEffect before any stock selection (SMG-0019). Read in EasyGLRenderer.cpp, OpenGL4StockDraw.cpp, SdlGpuRenderer.cpp, WebGPURenderer.cpp and VulkanRenderer.cpp at 009d40f5. Nothing was executed.

Texture uploads, readback and cube targets

Level-zero 2D uploads are implemented by every family. The higher-level hook ITextureRenderer::UpdatePixelsLevel defaults to a silent no-op, but at this snapshot every family except Stub overrides it (GDI through the Software texture code it recompiles): SDL_RENDERER, Canvas and FreeDirect throw for any level above zero, Software stores declared levels in CPU memory, and the GPU families upload real mip storage. WebGPU additionally regenerates mip chains in places the other families do not; that deliberate divergence is described in WebGPU: mip generation.

Readback is a completion contract. ITextureRenderer::GetData, the cube GetData/GetDataBytesEXT and the volume equivalents return true only when the complete requested region was written and false when nothing was read; the cube and volume SetData hooks have no default body at all, for the same reason. The shared layer hands the renderer a zero-initialised scratch buffer and converts it only on true; on false (or a null renderer) it raises System::NotSupportedException and leaves the caller's destination untouched. The source records why: a silent no-op default used to fabricate a complete, uniformly transparent-black image that passed both "did anything get written?" and any expectation that happened to be black. ITextureRenderer::HasDefinedMipLevel separates allocated from readable mip storage in the same spirit.

RenderTargetCube inherits TextureCube's public upload methods; whether a cube target accepts them is decided by its renderer object through the same completion contract, so an unsupported upload is an exception rather than a discarded write. Target transitions — finalising an outgoing face before another face, 2D target, MRT set or the back buffer becomes active — are family mechanisms; EasyGL finalises the old face when switching faces of the same cube (see EasyGL internals), and the deferred families have renderer-owned transition machinery described on their own pages.

Capability answers and why their polarity matters

GraphicsCapability has 19 members at this snapshot. The shared default in IGraphicsRenderer::SupportsCapability returns true for most of the original members and false only for MultiStreamVertexInput, CompiledEffects (routed through SupportsCompiledEffects) and the two float render-target members, with StencilBuffer delegated to SupportsStencilBuffer. That optimistic polarity is why a true answer is informative only when the family has audited the corresponding operation, and why GraphicsDevice::SupportsCapability derives six members from separate false-by-default virtuals rather than trusting a renderer switch that may end in default: return true. The per-identity answers are in the capability matrix; the query idiom is taught in Tutorial 101.

A list of capability contradictions recorded against an older revision is worth re-reading against this snapshot, because it shows how such gaps close:

CapabilityRecorded contradictionReading at 009d40f5
Multiple render targetsHeadless, Software and WebGPU answered true while rejecting or only tracing multi-target setsSoftware binds up to four CPU targets and WebGPU two to four; Headless validates and traces without pixels. Under the default Reach profile the device answers false everywhere, because it ANDs the renderer answer with the profile limit
Occlusion queryWebGPU, SDL_GPU and Software answered true with a null factory or bookkeeping onlyWebGPU and Software return real counts (Software an exact CPU sample count); SDL_GPU answers false and refuses creation; Headless is synthetic by design — its query always reports one visible sample
Custom effectsWebGPU answered true with an always-invalid effectWebGPU compiles WGSL ShaderEffect source (see modern GPU evidence tiers)
InstancingHeadless traced only; SDL_GPU inherited a true answer with a throwing callbackSDL_GPU overrides the instanced route; Headless still records without rasterising
MSAASoftware had target MSAA but none on its back buffer; several families negotiated a request down while answering trueSoftware applies a back-buffer sample count and resolves it for the terminal presenter; negotiation is still per family, so the applied count written back to PresentationParameters.MultiSampleCount or to a target's own MultiSampleCount is the stronger fact
Anisotropic filteringSDL_GPU and Software ignored the value; DirectX 9's answer ignored the device limitSDL_GPU clamps and applies it in its sampler key; Software's directional CPU sampler honours it against a device cap; DirectX 9 still inherits the default answer and forwards the requested value unclamped

Other gaps have no capability member at all: whether back-buffer readback exists, which image it returns, whether a clear is aspect-selective, whether viewport and scissor are rasterised. Those are covered on Presentation and back-buffer readback and Targets, clears and viewports.

The resource-side defaults for targets lean in opposite directions. IRenderTargetRenderer::GetMultiSampleCount() defaults to 0, which is conservative: no multisampling is claimed. HasRealDepthBuffer(requested) and the target's GetAppliedDepthStencilFormatEXT(requested) default to echoing the request, and HasRealStencilBuffer delegates to the depth answer, so a family that never allocates a depth plane can still appear to own the one the game asked for; only an override makes the answer truthful (the 2D-only SDL_RENDERER targets, for example, answer false). The device consults these answers to decide which clear aspects and discard planes exist, so the applied sample count and the real attachment presence are stronger facts than the constructor arguments.

The evidence ladder

For any single feature, the defensible sequence is: query (the capability or profile answer), factory (a non-null renderer object), operation (the call reaches the family without throwing), engagement (the family's native path, not a default, did the work — a registration, a trace, a GL error gate or a validation layer shows it), and a discriminating oracle (a pixel, readback or behavioural check that a plausible wrong result would fail). The same ladder applies across builds: dependency acquisition, host platform, device creation, feature engagement, readback and comparison are separate evidence stages, and passing an earlier one says nothing about a later one. An identity in the registry is the first rung only.

Recovery policy, debug hooks and markers

GraphicsDevice::SetContextRecoveryEnabled(bool) first changes shared policy and then calls the renderer hook. The shared half is real on every family: with recovery disabled, Texture2D::MaybeFreeCpuPixels drops a texture's CPU shadow after a full upload, so a later partial update or readback of that texture can fail even when the family's hook does nothing. Re-enabling does not reconstruct discarded pixels. The device also stores the flag and passes it as GraphicsRendererCreateArgs::contextRecoveryEnabled when a renderer is created. The header asks for the call "before the device is initialized", which cannot be literal for a member of an existing device; the workable window is after device construction and before content loading. At this snapshot DirectX 9, DirectX 11, DirectX 12, Direct2D, EasyGL, GDI and WebGPU override the renderer hook; EasyGL's use of it is described on EasyGL state and resource semantics.

F9 and F10 key-down events reach input first and then call DebugSimulateContextLoss and DebugRestoreContext, with no build guard; the routing and the families with non-empty overrides are on Game class: F9 and F10. Real device loss is a different, renderer-to-device channel (RendererDeviceEvent through the create arguments' callback), summarised on Graphics architecture: device loss.

SetStringMarkerEXT is a no-op by default. DirectX 9, FNA3D, GDI, Metal, OpenGL4 (a KHR_debug marker), SDL_GPU, Vulkan (a debug-utils label when the extension function exists) and WebGPU override it. A successful call is not proof that a native marker was emitted.

From identity to active family

A configure-time identity maps to one of 21 implementation families; the five GL-profile identities share the EasyGL family and every other identity is its own. Platform and dependency gates run per identity before the generated registry is written, so a family that could not be built never reaches the runtime. There is deliberately no global CreateGraphicsRenderer declared in the contract header any more: each family declares its factory in its own namespace, and the generated GraphicsRendererRegistry reaches it through GraphicsRendererDescriptor::create. The old single declaration was removed because it was a name every family factory had to be qualified against and an invitation for a new family to define a colliding global symbol, which is what once made two renderer archives unlinkable in one binary; a registered script check now fails on such a definition.

 configure time                              run time
 ─────────────────────────────────────       ─────────────────────────────────────────────
 CNA_GRAPHICS_RENDERER   (default)           GraphicsRendererSelection
 CNA_GRAPHICS_RENDERERS  (compiled set)        SetPreferred()  >  CNA_GRAPHICS_RENDERER env
        │                                      (or Module.cnaPreferredRenderer)  >  default
        ▼                                                 │ attempt order (+ opt-in fallback)
 per-identity host / dependency gates                     ▼
        │                                    GraphicsDevice::resolveRenderer
        ▼                                      descriptor.create(GraphicsRendererCreateArgs)
 generated registry ──────────────────────►     first successful creation LATCHES the family
 identity → Namespace::GetDescriptor                      │
                                                          ▼
   OPENGLES2 ┐                                 active family ──► native API / library
   OPENGLES3 │                                 (EasyGL: runtime GlProfile; FNA3D may pick
   OPENGL33  ├──► one EasyGL family             its own driver — still one CNA identity)
   WEBGL1    │    (GetDescriptorOpenGLES2 …
   WEBGL2    ┘     GetDescriptorWebGL2)
Figure. The configure step fixes the default identity and the compiled set, gates each identity by host and dependencies, and generates a registry that maps each identity to its family's descriptor accessor. At run time the selection resolves an explicit preference, then the environment variable (or the Emscripten page property), then the build default; the first device that successfully creates a renderer latches that family for the whole process, and the family then drives its native API. Five GL-profile identities map to one EasyGL family with a run-time profile, which is why 25 identities correspond to 21 families.

The selection is process-wide by design: the header's reason is that the choice has to be made before the first device exists, before a game has anywhere natural to keep per-instance state, and every later device in the process uses the latched family. The creation arguments carry the surface snapshot, the narrow platform service the descriptor asked for, virtual size, presentation mode, recovery policy, requested MSAA count and swap interval, back-buffer and depth formats, the full-screen flag, the profile and a device-event callback. Field presence is not a promise that every family consumes a field; backBufferFormat, depthStencilFormat and isFullScreen are documented as ignorable. Precedence, fallback and latch details are on Runtime renderer selection; the configure passes and the resolution loop are traced on Renderer selection internals.

What this means in practice

For application code:

  • choose a renderer for the operations and evidence your game needs, not for its name — an identity is a named implementation contract, not a parity promise;
  • treat capability answers as preflight hints and confirm applied values (MultiSampleCount, depth format, the device's GetGraphicsRendererType) or results;
  • keep graphics resources device-scoped, dispose texture copies before their device, and do not keep objects alive across an explicit device disposal;
  • distinguish logical presentation coordinates from physical readback pixels;
  • expect custom effects, multiple render targets, queries, instancing and readback to need family-specific qualification.

For a renderer implementation, overriding a virtual is only the start. A complete claim for one operation needs a reachable factory, the shared public routing, applied state, correct target and lifetime behaviour, evidence that the family's own path engaged, and a discriminating oracle. Fix a renderer bug and the family pages under Graphics backends show where each of those lives.

Evidence and limits

Every statement here was checked by reading the CNA source at 009d40f5; nothing was built or executed. The per-family lists ("overridden by …") come from searching each family's sources for an overriding declaration, which establishes that a family made a decision, not that the decision is correct; where a family's behaviour is described, the named function body was read. The historical contradictions in the capability table are shown only to explain how the current answers came about.

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