The renderer contract: IGraphicsRenderer defaults, factories and failure shapes

CNA snapshot 3b43bd86  ·  Deep Dives › Renderers  ·  source links pinned to 3b43bd86

✓

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 3b43bd86; 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 c6dec91f and re-checked at 3b43bd86; 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 8 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 3b43bd86 — 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, eleven interfaces and a lease token

The whole contract lives in IGraphicsRenderer.hpp (compiled XNA effects add ICompiledEffectRuntime from its own header, and the context-lease token IRendererThreadContextLease is complete in RendererThreadContextLease.hpp so that resource templates can hold it). Public XNA objects hold one renderer handle each; the renderer object answers device-wide operations. At this snapshot the header declares eleven interface classes: the XNA resource set and the renderer itself. There is no second, general-purpose GPU layer underneath them; CNA has no compute-shader, storage-buffer or GPU-timer interface.

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)
IRendererThreadContextLeaseA token that owns a GL context for one bounded operationAcquireThreadContextLeaseEXT (null by default; only EasyGL returns 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, CreateCompiledEffectDepends 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, 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, 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 sub-pixel sprite form is overridden only by EasyGL and Software, so fractional sprite destinations are quantised on every other family. SetDataWithOptions is overridden by every family except Stub (DirectX 9, EasyGL, FNA3D, Headless, SDL_GPU, Software and WebGPU), but an override does not mean the hint is honoured. Only DirectX 9 (D3DLOCK_DISCARD versus D3DLOCK_NOOVERWRITE), EasyGL (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. The WebGPU, Software and Headless overrides treat all three options identically (WebGPU because every draw snapshots its bytes, 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, STUB keeps the null render-target and occlusion-query defaults, and a family may also override a factory only to refuse (SDL_GPU, for example, throws from CreateOcclusionQuery because SDL_gpu has no query commands). 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. The 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”: an element a stock shader reads but the declaration omits is now refused by the shared layer before any family sees the draw (next section), and EasyGL (the shared RequireDeclarationMatchesStockProgram) additionally refuses a consumed element whose format it cannot convert. 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 . 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, EasyGL, FNA3D, SDL_GPU, Software and WebGPU; Stub keeps the throwing default, and Headless records the call without rasterising, so it proves routing, not instancing. Only SDL_GPU implements base-instance drawing (RRC-043).

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). At this snapshot CNA performs the same check once, in the shared layer, before any family sees the draw: GraphicsDevice::RequireStockEffectInputs (GraphicsDevice.cpp) requires Position0 always, Color0 when vertex colour is enabled, TextureCoordinate0 when a texture is, TextureCoordinate1 for the dual-texture effect, Normal0 when lighting is on, and BlendIndices0 with BlendWeight0 for a skinned draw. A missing element throws InvalidOperationException with XNA's wording, for example "The current vertex declaration does not include all the elements required by the current vertex shader. Normal0 is missing."

The check reads the union of every bound stream, per-instance streams included, because Direct3D 9 validates a shader's inputs against all of them, and it first applies FNA3D's declaration-collision rule (a repeated usage and index moves to the first free index of that usage), so a legitimate split TEXCOORD0/TEXCOORD0 layout is validated as the TEXCOORD0/TEXCOORD1 the renderer will consume. DrawUserPrimitives and DrawUserIndexedPrimitives are checked against the declaration they upload with. Four kinds of draw keep their own input contracts and are not checked: a compiled XNA effect, a custom ShaderEffect (its source decides what it reads), CNA's PBR extension, and the legacy route with an empty declaration, which has no elements to compare; a skinned draw on that route is refused outright, because its layout cannot be inferred from a stride.

On those exempt routes, and for any input a stock program declares beyond the checked set, the families fall back to a neutral value. StockVertexSemantics.hpp documents it as (0, 0, 0, 1): the value Direct3D 9 fills into a vertex register's missing components and the value OpenGL's disabled generic attribute holds, which is what the reference renderer, EasyGL, leaves such an input at; the header warns that a friendlier default such as white would quietly diverge. One exception landed after that rule was written: since 24 October 2026 EasyGL sets an absent colour input to white, the identity the vertex-colour multiply needs, because a colourless 48-byte PbrEffect record with VertexColorEnabledEXT on selects its colour program and otherwise drew black (EasyGLRenderer.cpp; every other absent input keeps GL's own default). The header's comment still describes the older rule. DIRECTX9 additionally checks its own BasicEffect and AlphaTestEffect draws against the declaration (DIRECTX9: which variants a draw can reach). Until 15 October 2026 there was no shared check, each family decided for itself, and the same draw that XNA refuses produced different pictures on different renderers; supply every semantic the effect variant reads, and test such draws by pixels. Read in GraphicsDevice.cpp and D3D9EffectDraw.cpp at c6dec91f and re-checked at 3b43bd86. 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: Software stores declared levels in CPU memory, Headless validates and traces the call, 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 17 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 questions rather than trusting a renderer switch that may end in default: return true: CompiledEffects and HalfFloatTextureLinearFiltering from false-by-default renderer virtuals, the two float render-target members from whether Vector4 and HdrBlendable are supported as render-target formats, and MultipleRenderTargets and Texture3D from the renderer answer combined with the profile's limits (so both are false under Reach). 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 3b43bd86
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; Headless, which applies 0 samples everywhere, answers false; 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 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 (as a no-op, because its managed-pool textures are restored by the Direct3D 9 runtime), EasyGL 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 (Direct3D 9's D3DPERF_SetMarker), FNA3D, SDL_GPU 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 8 implementation families; the three 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                      │
                                                          ▼
   OPENGLES3 ┐                                 active family ──► native API / library
   OPENGL33  ├──► one EasyGL family             (EasyGL: runtime GlProfile; FNA3D may pick
   WEBGL2    ┘    (GetDescriptorOpenGLES3 …      its own driver — still one CNA identity)
                   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. Three GL-profile identities map to one EasyGL family with a run-time profile, which is why 10 identities correspond to 8 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 c6dec91f and re-checked at 3b43bd86; 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