Textures and render targets

CNA snapshot 009d40f5  ·  Development › Graphics internals  ·  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. The listed tests exist at this snapshot; none was run for this page. Per-family native allocation, upload and destruction are covered only for Vulkan here.

The public texture is not the native texture. GraphicsResource registers a disposable C++ object with its device; Texture2D holds a shareable renderer-side ITextureRenderer; RenderTarget2D adds attachment semantics and a binding relationship with the device. These lifetimes overlap but are not interchangeable, and most resource bugs come from treating one as another. This page follows construction, upload, binding and disposal through the neutral layer and into Vulkan's frame-gated retirement.

Texture2D construction and data path

Texture2D(device, width, height, mipMap, format)
  → Texture / GraphicsResource(&device): register this, ResourceCreated, weak device-lifetime token
  → ValidateTextureSizeForProfileEXT, ValidateTextureDimensionEXT,
    ValidateTexture2DFormatEXT, ValidateTexture2DCreationShapeEXT
  → ImageData{ width, height, mipLevels, surfaceFormat, zero-filled level 0 }
       (block-compressed formats: ceil(w/4) × ceil(h/4) blocks, not w × h texels)
  → IGraphicsRenderer::CreateTexture(ImageData)  → unique_ptr<ITextureRenderer>
       held by Texture2D::renderer_ as shared_ptr (value copies share it)
  → cpuPixels_ (shared CPU shadow) → renderer_->ShareCpuPixels(cpuPixels_) → MaybeFreeCpuPixels()
  → SetData / GetData … bind through TextureCollection or an effect → sampled at draw time
  → Texture2D::Dispose(bool): DetachDestroyedRenderTarget(this) if the device is alive,
                              renderer_.reset(), Texture::Dispose → GraphicsResource::Dispose

The constructors in Texture2D.cpp differ in what they promise. Texture2D(assetName, device) decodes through ImageLoader::Load and immediately creates the backend texture. Texture2D(assetName) loads CPU pixels but has no device, so renderer_ stays null until the texture is attached; treating every Texture2D as GPU-backed is wrong. The dimension constructors validate the profile's size limit and the renderer's dimension, and the format constructor also validates the format and the shape (mip chain, block alignment) against the active renderer. Allocation is always IGraphicsRenderer::CreateTexture(ImageData), never GraphicsResource.

The CPU shadow is a context-loss copy. MaybeFreeCpuPixels() drops cpuPixels_ only when the device's context recovery is disabled (GraphicsDevice::SetContextRecoveryEnabled(false)); by default the shadow is kept and shared with the renderer through ShareCpuPixels(), which is what lets context-backed families re-upload after a loss. A later partial SetData on level 0 of an ordinary texture whose shadow was freed throws, rather than re-uploading a zero-filled buffer over the untouched texels. A new upload or readback path must therefore check both MaybeFreeCpuPixels() and the specific family's storage assumptions.

Renderer objects are shareable. Texture2D's copy constructor and copy assignment are defaulted (CNAEXT), so a value copy — such as the copies ContentManager hands out from its cache — shares the same renderer_ and CPU shadow and adds no registry entry. Two consequences follow in the source:

  • A full-level SetData(const Color*, int) on an ordinary texture builds a fresh renderer object instead of updating in place, so one wrapper's upload is never published to other holders of the cached renderer. Texture2DCacheReconstructionTests.cpp and the content-side cache isolation tests pin this.
  • A renderer object can outlive both the wrapper that created it and the family's renderer. On Vulkan, VulkanRenderer::CreateTexture records each VulkanTextureRenderer in liveTextures_; the texture removes itself on destruction, and the renderer's teardown releases the Vulkan resources of every still-listed texture and disconnects it from the owner, so a surviving wrapper never touches a destroyed VkDevice.

Why registration is not ownership

GraphicsResource(GraphicsDevice*) appends its address to GraphicsDevice::resources_, raises ResourceCreated, and copies a weak_ptr to the device's resourceDeviceLifetime_. The device uses the list to dispose resources while the renderer still exists. GraphicsResource::Dispose(bool) on an ordinary resource returns if already disposed, raises Disposing only on the explicit path, and — only if the weak token has not expired — raises the device's ResourceDestroyed and unregisters, then marks itself disposed. A C++ resource object may outlive its device; the token is what prevents dereferencing the stale raw graphicsDevice_.

Value-style states (BlendState, DepthStencilState, RasterizerState, SamplerState, VertexDeclaration) use a SharedIdentity: copy construction registers nothing, and copy assignment (plus VertexDeclaration's copy constructor) calls ShareResourceIdentityWith(), which makes the assigned object an alias of one canonical object whose device, name, tag and disposed flag are shared. The copy constructors of the four state classes instead produce an independent, mutable copy (CNA's spelling of XNA's new BlendState()), which is not an alias. Disposing any alias marks all of them disposed and, on the explicit path, raises Disposing on each with the canonical object as sender; destroying an alias only detaches it. This is why SpriteBatch::Begin assigning four states does not flood the create/destroy event stream. Moves transfer the registry entry with TransferResourceReference() (and, for shared identities, re-point the alias), and a moved texture also transfers its texture-slot and render-target bindings through TransferMovedTexture(). Any change to copy, move or dispose needs tests that cover both ordinary resources and shared identities.

When the device itself disposes, it marks itself disposed before callbacks, moves and clears the registry, and calls each tracked resource's public Dispose(); only then does it destroy the renderer and the window. Re-entrant unregistering during that loop is therefore harmless. Do not replace the registry with owning unique_ptrs without redesigning public resource allocation and value semantics: the pointer list is a disposal-order mechanism, not an allocator. The full disposal order is on GraphicsDevice internals.

RenderTarget2D is a texture with attachment policy

RenderTarget2D computes its format and renderer before the Texture2D base receives them. SelectRenderTargetFormatEXT implements XNA's “preferred” semantics: a format the profile forbids, or that the renderer classifies as Unsupported or Defer, becomes Color. CreateValidatedRenderTargetRenderer then checks positive dimensions, the profile's maximum size (widened only inside the CNAEXT engine layer's texture-size scope), a 2048:1 aspect-ratio limit and the renderer's format verdict, rounds the multisample request down to a power of two, and calls CreateRenderTarget2DEXT. A family without render-target storage returns null and construction still succeeds; binding such a target later throws NotSupportedException (see which families have real render targets).

The protected base constructor marks the content GPU-only (gpuOnlyContent_): the live renderer is the sole authority for the pixels, so a full-level SetData updates the target in place and keeps no CPU shadow, and a partial update seeds itself from the renderer's GetData (or throws if the family cannot read back). The target records the depth format the renderer actually applied and the renderer-clamped sample count, not the requested ones. rtRenderer_ is a borrowed IRenderTargetRenderer* into the object the base texture's renderer_ owns; RenderTarget2D::Dispose clears it after base disposal so GetRenderTargetRenderer() cannot return a dangling pointer. RenderTargetCube has its own face-binding path (RenderTargetBindingDescriptor::ForRenderTargetCubeFace); it is not a disguised 2D target.

SetRenderTargets: neutral transaction

GraphicsDevice::SetRenderTargets — the path every SetRenderTarget overload now funnels into, so there is one place to keep correct:

  1. Compare resource identity and cube face with the current set. An unchanged set returns immediately, without resolving, discarding or resetting viewport and scissor; an empty-to-empty call is therefore a no-op — except after a bound target was destroyed, when boundRenderTargetDestroyed_ forces a real transition even though no binding remains to compare.
  2. Enforce XNA's four-binding ceiling (MAX_RENDERTARGET_BINDINGS), then the renderer's GetMaxRenderTargetsForProfileEXT() (default: one under Reach, four under HiDef).
  3. Empty set: renderer_->SetRenderTargets(nullptr, 0) first; only after it returns clear the public bindings and both flags and reset viewport and scissor to the back-buffer size.
  4. For each binding reject null, disposed and foreign-device targets; convert a RenderTarget2D (array slice must be 0) or a cube face (face range checked) into a RenderTargetBindingDescriptor; a missing renderer object is NotSupportedException.
  5. Reject a resource bound to two slots, mismatched dimensions, mismatched applied sample counts and mismatched pixel sizes. Equal byte size is compatible even when the exact formats differ, as in XNA.
  6. Call renderer_->SetRenderTargets(descriptors, count) before touching public state. Only after it succeeds: clear each target's content-lost flag, publish currentRenderTargets_, set renderTargetBound_, clear boundRenderTargetDestroyed_, reset viewport and scissor to the first target, and for DiscardContents clear colour to opaque black plus whichever depth and stencil buffers really exist.

Because a target is a writable attachment, reordering these steps could report a successful bind, or restored content, while the native bind threw. GraphicsDevice::Present() refuses to present while renderTargetBound_ is true; code that implements “render target to back buffer” must unbind through SetRenderTargets and then present. Changing only the renderer's native target without the device's public flag breaks the invariant. Reset() unbinds for the same reason. The user-level contract is on Render targets: setting the render target and restoring the back buffer.

Why Vulkan cannot free on unbind

VulkanRenderer::SetRenderTargets routes a single 2D target to VulkanRenderTargetRenderer::BindAsRenderTarget() and a single cube face to BindAsRenderTargetFace(). For two or more targets it builds a VulkanMRTProxy, which refuses more than four targets or more than the device's maxColorAttachments, requires independentBlend, derives each attachment's colour, multisample, resolve and depth views and sample count, rejects mismatched dimensions or sample counts and a subresource bound twice, obtains a matching render pass, and creates its own VkFramebuffer. An empty set begins a new back-buffer render-pass segment.

The proxy is a destination that deferred draw records reference. On replacement or return to the back buffer the old proxy is moved to retiredMrtProxies_, tagged with the current frame generation, rather than destroyed. ProcessRetiredResources(), called at the start of each SubmitFrame after the fence wait, frees a proxy only when its generation is more than MaxFramesInFlight frames old — and frees proxies before the same-generation resource buckets, because the proxy's framebuffer borrows its targets' views. Freeing the framebuffer at unbind time would invalidate work still queued or executing on the GPU. This is a family-specific lifetime rule layered underneath the neutral transaction; the Vulkan page covers the rest of its retirement machinery.

Bound target disposal has two paths

RenderTarget2D::Dispose(bool) throws InvalidOperationException (“Disposing target that is still bound”) when the target is still in the live device's bindings. A C++ destructor cannot throw, and by the time ~Texture2D runs the object is no longer a RenderTarget2D, so the destructor path reaches Texture2D::Dispose(bool), which calls GraphicsDevice::DetachDestroyedRenderTarget(this) before resetting the backend object. That helper, when the target is bound:

  • asks the renderer to return to the back buffer while the target's backend still exists (errors swallowed — it is noexcept), because not every family detaches a dying backend itself (DirectX 11/12 and EasyGL do; Software keeps a raw pointer it would only unbind on the next transition);
  • clears currentRenderTargets_, so GetRenderTargets(), draws and the identity comparison can never see the dead pointer (or treat a new target built at the same address as already bound);
  • sets boundRenderTargetDestroyed_ and deliberately leaves renderTargetBound_ set, so Present() keeps refusing until the caller explicitly calls SetRenderTargets again.

The split prevents use-after-free without pretending a valid back-buffer transition occurred. Move-assigning over a bound RenderTarget2D is also rejected (“Moving over a render target that is still bound”). The shared fixture bound_target_lifetime_test.cpp is registered as <Family>_BoundTargetLifetime for EasyGL, Headless, SDL_gpu, Software, Vulkan and WebGPU, as OpenGL4_EasyGLParity_BoundTargetLifetime through the OpenGL4 parity corpus, and as Resource_BoundTargetLifetime in the DirectX 11/12 inventory DirectXParityTests.cmake.

Change and validation guide

For a new surface format, start with the public shape and format checks in Texture.cpp and Texture2D, then the renderer's ClassifySurfaceFormatEXT and ClassifyRenderTargetFormatEXT (a family with no classifier accepts Color only), then upload, readback and attachment for each family that claims it. Test ordinary sampling and render-target use separately; a format can support one and not the other.

For a render-target transition bug, first establish which of three things happened: validation failed before the renderer was called, the renderer threw during the transition, or public state diverged after a successful native call. Cover same-binding, empty-to-empty, cube face, MRT, cross-device, move, destruction while bound and Present-after-unbind. For a texture upload bug, cover an ordinary texture, a value copy from the content cache, a render target, and the context-recovery-disabled case. Renderer conformance is required beyond neutral unit tests; Vulkan additionally needs validation and multi-frame retirement coverage.

Test sourceEstablishes
Texture2DTests.cppPublic texture construction, format acceptance per renderer, transfer contracts.
Texture2DCacheReconstructionTests.cppCPU-shadow round trips, detaching on upload, no aliasing between reconstructed wrappers, render targets keeping in-place semantics.
RenderTargetSemanticsTests.cpp, RenderTargetFormatAgreementTests.cppBinding semantics, and agreement between SupportsSurfaceFormatAsRenderTargetEXT and the constructor's preferred-format selection for every SurfaceFormat.
bound_target_lifetime_test.cpp and the Vulkan Vulkan_DeferredResourceLifetime, Vulkan_MRT_MsaaResolve, Vulkan_MRT_MixedFormats registrationsDestruction while bound, and deferred retirement on the family that defers.

These tests establish public semantics; they do not necessarily cover every native format translation or GPU completion order, and none was run for this page.

Read in lifecycle order

  1. GraphicsResource.cpp: registration, the weak lifetime token and shared identities, before any concrete resource.
  2. Texture2D.cpp: constructors, MaybeFreeCpuPixels, the full-level and partial SetData paths, move and Dispose.
  3. RenderTarget2D.cpp: format selection, validated native creation, applied parameters, bound-disposal and bound-move refusal.
  4. GraphicsDevice.cpp: SetRenderTargets, DetachDestroyedRenderTarget, TransferMovedTexture, Present and Dispose.
  5. VulkanRenderer.cpp: CreateTexture, SetRenderTargets, VulkanMRTProxy, SubmitFrame and ProcessRetiredResources.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture